diff --git a/package-lock.json b/package-lock.json
index 9aec185031..3ffd3e27e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,6 +5,7 @@
"requires": true,
"packages": {
"": {
+ "name": "dsp-app",
"version": "5.3.0",
"dependencies": {
"@angular/animations": "^11.2.9",
@@ -4916,6 +4917,7 @@
"version": "9.0.0",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"tslib": "^1.10.0"
}
@@ -4924,6 +4926,7 @@
"version": "9.0.0",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"rxjs": "^6.5.3",
"tslib": "^1.10.0",
@@ -12311,6 +12314,7 @@
"node_modules/pdfjs-dist": {
"version": "2.7.570",
"license": "Apache-2.0",
+ "peer": true,
"peerDependencies": {
"worker-loader": "^3.0.7"
}
@@ -21888,8 +21892,6 @@
"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",
@@ -21907,11 +21909,13 @@
"@angular/compiler": {
"version": "9.0.0",
"dev": true,
+ "peer": true,
"requires": {}
},
"@angular/core": {
"version": "9.0.0",
"dev": true,
+ "peer": true,
"requires": {}
},
"source-map": {
@@ -26180,7 +26184,6 @@
"ng2-pdf-viewer": {
"version": "7.0.1",
"requires": {
- "pdfjs-dist": "~2.7.570",
"tslib": "^2.0.0"
}
},
@@ -26985,6 +26988,7 @@
},
"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 6969afeda0..ef3c6ba794 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -98,6 +98,22 @@ import { ResultsComponent } from './workspace/results/results.component';
import { AudioComponent } from './workspace/resource/representation/audio/audio.component';
import { IntermediateComponent } from './workspace/intermediate/intermediate.component';
import { ResourceLinkFormComponent } from './workspace/resource/resource-link-form/resource-link-form.component';
+import { ConfirmationDialogComponent } from './main/action/confirmation-dialog/confirmation-dialog.component';
+import { ConfirmationMessageComponent } from './main/action/confirmation-dialog/confirmation-message/confirmation-message.component';
+import { LoginFormComponent } from './main/action/login-form/login-form.component';
+import { MessageComponent } from './main/action/message/message.component';
+import { ProgressIndicatorComponent } from './main/action/progress-indicator/progress-indicator.component';
+import { StringLiteralInputComponent } from './main/action/string-literal-input/string-literal-input.component';
+import { SortButtonComponent } from './main/action/sort-button/sort-button.component';
+import { SelectedResourcesComponent } from './main/action/selected-resources/selected-resources.component';
+import { AdminImageDirective } from './main/directive/admin-image/admin-image.directive';
+import { ExistingNameDirective } from './main/directive/existing-name/existing-name.directive';
+import { GndDirective } from './main/directive/gnd/gnd.directive';
+import { FormattedBooleanPipe } from './main/pipes/formatting/formatted-boolean.pipe';
+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';
// translate: AoT requires an exported function for factories
export function httpLoaderFactory(httpClient: HttpClient) {
@@ -182,6 +198,22 @@ export function httpLoaderFactory(httpClient: HttpClient) {
AudioComponent,
IntermediateComponent,
ResourceLinkFormComponent,
+ ConfirmationDialogComponent,
+ ConfirmationMessageComponent,
+ LoginFormComponent,
+ MessageComponent,
+ ProgressIndicatorComponent,
+ StringLiteralInputComponent,
+ SortButtonComponent,
+ SelectedResourcesComponent,
+ AdminImageDirective,
+ ExistingNameDirective,
+ GndDirective,
+ FormattedBooleanPipe,
+ KnoraDatePipe,
+ LinkifyPipe,
+ StringifyStringLiteralPipe,
+ TruncatePipe,
],
imports: [
AppRoutingModule,
diff --git a/src/app/main/action/confirmation-dialog/confirmation-dialog.component.html b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.html
new file mode 100644
index 0000000000..cd02c1ec84
--- /dev/null
+++ b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.html
@@ -0,0 +1,11 @@
+
+
+ Are you sure you want to delete this value from {{data.value.propertyLabel}}?
+
+
+
+
+
+
+
+
diff --git a/src/app/main/action/confirmation-dialog/confirmation-dialog.component.scss b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.scss
new file mode 100644
index 0000000000..0a5a960ec2
--- /dev/null
+++ b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.scss
@@ -0,0 +1 @@
+@import "../../../../assets/style/viewer";
diff --git a/src/app/main/action/confirmation-dialog/confirmation-dialog.component.spec.ts b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.spec.ts
new file mode 100644
index 0000000000..dac46a0528
--- /dev/null
+++ b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.spec.ts
@@ -0,0 +1,168 @@
+import { OverlayContainer } from '@angular/cdk/overlay';
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { Component, Input, OnInit } from '@angular/core';
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatButtonHarness } from '@angular/material/button/testing';
+import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { MatDialogHarness } from '@angular/material/dialog/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { MockResource, ReadIntValue, ReadValue } from '@dasch-swiss/dsp-js';
+import { ConfirmationDialogComponent, ConfirmationDialogValueDeletionPayload } from './confirmation-dialog.component';
+
+/**
+ * test host component to simulate parent component with a confirmation dialog.
+ */
+@Component({
+ template: `
+ {{confirmationDialogResponse}}
`
+})
+class ConfirmationDialogTestHostComponent implements OnInit {
+
+ confirmationDialogResponse: string;
+
+ testValue: ReadIntValue;
+
+ constructor(private dialog: MatDialog) {
+ }
+
+ ngOnInit() {
+ MockResource.getTestThing().subscribe(res => {
+ this.testValue = res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger', ReadIntValue)[0];
+ });
+ }
+
+ openDialog() {
+
+ this.dialog.open(ConfirmationDialogComponent, {
+ data: {
+ value: this.testValue,
+ buttonTextOk: 'OK',
+ buttonTextCancel: 'Cancel'
+ }
+ }).afterClosed().subscribe((payload: ConfirmationDialogValueDeletionPayload) => {
+ if (payload.confirmed) {
+ this.confirmationDialogResponse = 'Action was confirmed!';
+ } else {
+ this.confirmationDialogResponse = 'Action was cancelled';
+ }
+ });
+ }
+}
+
+@Component({ selector: 'app-confirmation-message', template: '' })
+class MockConfirmationMessageComponent {
+ @Input() value: ReadValue;
+
+ constructor() { }
+}
+
+describe('ConfirmationDialogComponent', () => {
+ let testHostComponent: ConfirmationDialogTestHostComponent;
+ let testHostFixture: ComponentFixture;
+ let rootLoader: HarnessLoader;
+ let overlayContainer: OverlayContainer;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ ConfirmationDialogComponent,
+ ConfirmationDialogTestHostComponent,
+ MockConfirmationMessageComponent
+ ],
+ imports: [
+ MatDialogModule,
+ BrowserAnimationsModule
+ ],
+ providers: [
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: {}
+ },
+ {
+ provide: MatDialogRef,
+ useValue: {}
+ }
+ ]
+ })
+ .compileComponents();
+
+ overlayContainer = TestBed.inject(OverlayContainer);
+ }));
+
+ beforeEach(() => {
+ testHostFixture = TestBed.createComponent(ConfirmationDialogTestHostComponent);
+ testHostComponent = testHostFixture.componentInstance;
+ rootLoader = TestbedHarnessEnvironment.documentRootLoader(testHostFixture);
+ testHostFixture.detectChanges();
+ expect(testHostComponent).toBeTruthy();
+ });
+
+ 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 display a confirmation dialog', async () => {
+
+ testHostComponent.openDialog();
+
+ testHostFixture.detectChanges();
+
+ await testHostFixture.whenStable();
+
+ const dialogDiv = document.querySelector('mat-dialog-container');
+ expect(dialogDiv).toBeTruthy();
+
+ const dspConfirmMsg = document.querySelector('app-confirmation-message');
+ expect(dspConfirmMsg).toBeTruthy();
+
+ const dialogTitle = dialogDiv.querySelector('.title');
+ expect(dialogTitle.innerHTML.trim()).toEqual('Are you sure you want to delete this value from Integer?');
+
+ });
+
+ it('should return a confirmation message when the OK button is clicked', async () => {
+
+ testHostComponent.openDialog();
+
+ let dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness);
+
+ expect(dialogHarnesses.length).toEqual(1);
+
+ const okButton = await rootLoader.getHarness(MatButtonHarness.with({ selector: '.ok' }));
+
+ await okButton.click();
+
+ dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness);
+
+ expect(dialogHarnesses.length).toEqual(0);
+
+ expect(testHostComponent.confirmationDialogResponse).toEqual('Action was confirmed!');
+
+ });
+
+ it('should return a cancelled message when the cancel button is clicked', async () => {
+
+ testHostComponent.openDialog();
+
+ let dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness);
+
+ expect(dialogHarnesses.length).toEqual(1);
+
+ const cancelButton = await rootLoader.getHarness(MatButtonHarness.with({ selector: '.cancel' }));
+
+ await cancelButton.click();
+
+ dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness);
+
+ expect(dialogHarnesses.length).toEqual(0);
+
+ expect(testHostComponent.confirmationDialogResponse).toEqual('Action was cancelled');
+
+
+ });
+});
diff --git a/src/app/main/action/confirmation-dialog/confirmation-dialog.component.ts b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.ts
new file mode 100644
index 0000000000..79ac60c1b2
--- /dev/null
+++ b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.ts
@@ -0,0 +1,39 @@
+import { Component, Inject, ViewChild } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { ReadValue } from '@dasch-swiss/dsp-js';
+import { ConfirmationMessageComponent } from './confirmation-message/confirmation-message.component';
+
+export class ConfirmationDialogData {
+ value: ReadValue;
+ buttonTextOk: string;
+ buttonTextCancel: string;
+}
+
+export class ConfirmationDialogValueDeletionPayload {
+ confirmed: boolean;
+ deletionComment?: string;
+}
+
+@Component({
+ selector: 'app-confirmation-dialog',
+ templateUrl: './confirmation-dialog.component.html',
+ styleUrls: ['./confirmation-dialog.component.scss']
+})
+export class ConfirmationDialogComponent {
+ @ViewChild('confirmMessage') confirmationMessageComponent: ConfirmationMessageComponent;
+
+ // type assertion doesn't seem to be enforced
+ // https://stackoverflow.com/a/57787554
+ constructor(
+ @Inject(MAT_DIALOG_DATA) public data: ConfirmationDialogData,
+ private _dialogRef: MatDialogRef
+ ) { }
+
+ onConfirmClick(): void {
+ const payload = new ConfirmationDialogValueDeletionPayload();
+ payload.confirmed = true;
+ payload.deletionComment = this.confirmationMessageComponent.comment ? this.confirmationMessageComponent.comment : undefined;
+ this._dialogRef.close(payload);
+ }
+
+}
diff --git a/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.html b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.html
new file mode 100644
index 0000000000..49fdbbf3ef
--- /dev/null
+++ b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.html
@@ -0,0 +1,13 @@
+
+
Confirming this action will delete the following value from {{value.propertyLabel}}:
+
Value: {{value.strval}}
+
+
Value Creation Date: {{value.valueCreationDate}}
+
+
diff --git a/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.scss b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.scss
new file mode 100644
index 0000000000..44169e9db7
--- /dev/null
+++ b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.scss
@@ -0,0 +1 @@
+@import "../../../../../assets/style/viewer";
diff --git a/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.spec.ts b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.spec.ts
new file mode 100644
index 0000000000..1f2d313ec5
--- /dev/null
+++ b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.spec.ts
@@ -0,0 +1,88 @@
+import { Component, OnInit } from '@angular/core';
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { MockResource, ReadIntValue } from '@dasch-swiss/dsp-js';
+import { ConfirmationMessageComponent } from './confirmation-message.component';
+
+/**
+ * test host component to simulate parent component with a confirmation dialog.
+ */
+@Component({
+ template: `
+ `
+})
+class ConfirmationMessageTestHostComponent implements OnInit {
+
+ testValue: ReadIntValue;
+
+ constructor() {
+ }
+
+ ngOnInit() {
+ MockResource.getTestThing().subscribe(res => {
+ this.testValue = res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger', ReadIntValue)[0];
+ });
+ }
+}
+
+describe('ConfirmationMessageComponent', () => {
+ let testHostComponent: ConfirmationMessageTestHostComponent;
+ let testHostFixture: ComponentFixture;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ ConfirmationMessageTestHostComponent,
+ ConfirmationMessageComponent
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ testHostFixture = TestBed.createComponent(ConfirmationMessageTestHostComponent);
+ testHostComponent = testHostFixture.componentInstance;
+ testHostFixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(testHostComponent).toBeTruthy();
+ });
+
+ it('should bind the values correctly', () => {
+ testHostComponent.testValue.valueHasComment = 'My comment';
+ testHostFixture.detectChanges();
+
+ const hostCompDe = testHostFixture.debugElement;
+ const valueComponentDe = hostCompDe.query(By.directive(ConfirmationMessageComponent));
+
+ expect(valueComponentDe).toBeTruthy();
+
+ const label = valueComponentDe.query(By.css('.val-label')).nativeElement;
+ expect(label.innerText).toEqual('Confirming this action will delete the following value from Integer:');
+
+ const value = valueComponentDe.query(By.css('.val-value')).nativeElement;
+ expect(value.innerText).toEqual('Value: 1');
+
+ const comment = valueComponentDe.query(By.css('.val-comment')).nativeElement;
+ expect(comment.innerText).toEqual('Value Comment: My comment');
+
+ const creationDate = valueComponentDe.query(By.css('.val-creation-date')).nativeElement;
+ expect(creationDate.innerText).toEqual('Value Creation Date: 2018-05-28T15:52:03.897Z');
+
+ });
+
+ it('should default to "no comment" if the value does not contain a comment', () => {
+ testHostComponent.testValue.valueHasComment = null;
+ testHostFixture.detectChanges();
+
+ const hostCompDe = testHostFixture.debugElement;
+ const valueComponentDe = hostCompDe.query(By.directive(ConfirmationMessageComponent));
+
+ expect(valueComponentDe).toBeTruthy();
+
+ const comment = valueComponentDe.query(By.css('.val-comment')).nativeElement;
+ expect(comment.innerText).toEqual('Value Comment: No comment');
+
+ });
+});
diff --git a/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.ts b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.ts
new file mode 100644
index 0000000000..87b7a62339
--- /dev/null
+++ b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.ts
@@ -0,0 +1,20 @@
+import { Component, Input } from '@angular/core';
+import { ReadValue } from '@dasch-swiss/dsp-js';
+
+@Component({
+ selector: 'app-confirmation-message',
+ templateUrl: './confirmation-message.component.html',
+ styleUrls: ['./confirmation-message.component.scss']
+})
+export class ConfirmationMessageComponent {
+
+ @Input() value: ReadValue;
+ comment?: string;
+
+ constructor() { }
+
+ onKey(event: KeyboardEvent) {
+ this.comment = (event.target as HTMLInputElement).value;
+ }
+
+}
diff --git a/src/app/main/action/login-form/login-form.component.html b/src/app/main/action/login-form/login-form.component.html
new file mode 100644
index 0000000000..347f1b3bdd
--- /dev/null
+++ b/src/app/main/action/login-form/login-form.component.html
@@ -0,0 +1,67 @@
+
+
+
+
+
A user is already logged in:
+
Username: {{session.user.name}}
+
+
Please log out if it's not you.
+
+
diff --git a/src/app/main/action/login-form/login-form.component.scss b/src/app/main/action/login-form/login-form.component.scss
new file mode 100644
index 0000000000..7378eb40f1
--- /dev/null
+++ b/src/app/main/action/login-form/login-form.component.scss
@@ -0,0 +1,38 @@
+$warn: #ef5350;
+
+$width: 280px;
+
+.full-width {
+ width: 100%;
+}
+
+.login-form-title {
+ text-align: center;
+}
+
+.login-error {
+ color: $warn;
+ text-align: center;
+}
+
+.login-progress {
+ display: inline-block;
+ margin-right: 6px;
+}
+
+.login-container,
+.logout-container {
+ margin-left: auto;
+ margin-right: auto;
+ position: relative;
+ width: $width;
+}
+
+.submit-button,
+.logout-button {
+ height: 48px;
+}
+
+.center {
+ text-align: center;
+}
diff --git a/src/app/main/action/login-form/login-form.component.spec.ts b/src/app/main/action/login-form/login-form.component.spec.ts
new file mode 100644
index 0000000000..b32d8c7b6e
--- /dev/null
+++ b/src/app/main/action/login-form/login-form.component.spec.ts
@@ -0,0 +1,225 @@
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
+import { MatInputModule } from '@angular/material/input';
+import { MatSnackBarModule } from '@angular/material/snack-bar';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import {
+ ApiResponseData,
+ AuthenticationEndpointV2,
+ LoginResponse,
+ LogoutResponse,
+ MockUsers,
+ UsersEndpointAdmin
+} from '@dasch-swiss/dsp-js';
+import { of } from 'rxjs';
+import { AjaxResponse } from 'rxjs/ajax';
+import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens';
+import { Session, SessionService } from '../../services/session.service';
+import { LoginFormComponent } from './login-form.component';
+
+/**
+ * test host component to simulate login-form component.
+ */
+@Component({
+ template: `
+ `
+})
+class TestHostComponent implements OnInit {
+
+ @ViewChild('loginForm') loginFormComponent: LoginFormComponent;
+
+ loggedIn: boolean;
+
+ ngOnInit() { }
+
+ // if response is true, the login was successful
+ // assign loggedIn to the response
+ onLogin(response: boolean) {
+ this.loggedIn = response;
+ }
+
+ // if response is true, the logout was successful
+ // assign loggedIn to the opposite of the response
+ onLogout(response: boolean) {
+ this.loggedIn = !response;
+ }
+
+}
+
+describe('LoginFormComponent', () => {
+ let testHostComponent: TestHostComponent;
+ let testHostFixture: ComponentFixture;
+
+ let sessionService: SessionService;
+
+ beforeEach(waitForAsync(() => {
+ const dspConnSpy = {
+ admin: {
+ usersEndpoint: jasmine.createSpyObj('usersEndpoint', ['getUser'])
+ },
+ v2: {
+ auth: jasmine.createSpyObj('auth', ['login', 'logout']),
+ jsonWebToken: ''
+ },
+ };
+
+ TestBed.configureTestingModule({
+ declarations: [
+ LoginFormComponent,
+ TestHostComponent
+ ],
+ providers: [
+ {
+ provide: DspApiConnectionToken,
+ useValue: dspConnSpy
+ },
+ FormBuilder,
+ SessionService
+ ],
+ imports: [
+ ReactiveFormsModule,
+ MatInputModule,
+ MatSnackBarModule,
+ BrowserAnimationsModule
+ ]
+ })
+ .compileComponents();
+
+ sessionService = TestBed.inject(SessionService);
+ }));
+
+ // mock localStorage
+ beforeEach(() => {
+ let store = {};
+
+ spyOn(localStorage, 'getItem').and.callFake(
+ (key: string): string => store[key] || null
+ );
+ spyOn(localStorage, 'removeItem').and.callFake(
+ (key: string): void => {
+ delete store[key];
+ }
+ );
+ spyOn(localStorage, 'setItem').and.callFake(
+ (key: string, value: string): void => {
+ store[key] = value;
+ }
+ );
+ spyOn(localStorage, 'clear').and.callFake(() => {
+ store = {};
+ });
+ });
+
+ beforeEach(() => {
+ testHostFixture = TestBed.createComponent(TestHostComponent);
+ testHostComponent = testHostFixture.componentInstance;
+ testHostFixture.detectChanges();
+
+ expect(testHostComponent).toBeTruthy();
+ });
+
+ it('should create an instance', () => {
+ expect(testHostComponent.loginFormComponent).toBeTruthy();
+ });
+
+ describe('Login', () => {
+
+ beforeEach(() => {
+ const dspConnSpy = TestBed.inject(DspApiConnectionToken);
+
+ (dspConnSpy.v2.auth as jasmine.SpyObj).login.and.callFake(
+ () => {
+ const response: LoginResponse = new LoginResponse();
+
+ response.token = 'myToken';
+
+ return of(ApiResponseData.fromAjaxResponse({ response } as AjaxResponse));
+ }
+ );
+
+ (dspConnSpy.admin.usersEndpoint as jasmine.SpyObj).getUser.and.callFake(
+ () => {
+ const loggedInUser = MockUsers.mockUser();
+ return of(loggedInUser);
+ }
+ );
+ });
+
+ it('should log the user in if the credentials are correct', () => {
+
+ const dspConnSpy = TestBed.inject(DspApiConnectionToken);
+
+ testHostComponent.loginFormComponent.form.get('username').setValue('root');
+
+ testHostComponent.loginFormComponent.form.get('password').setValue('test');
+
+ testHostFixture.detectChanges();
+
+ testHostComponent.loginFormComponent.login();
+
+ expect(dspConnSpy.v2.auth.login).toHaveBeenCalledTimes(1);
+
+ expect(dspConnSpy.v2.auth.login).toHaveBeenCalledWith('username', 'root', 'test');
+
+ expect(dspConnSpy.admin.usersEndpoint.getUser).toHaveBeenCalledTimes(1);
+
+ expect(dspConnSpy.admin.usersEndpoint.getUser).toHaveBeenCalledWith('username', 'root');
+
+ const session = JSON.parse(localStorage.getItem('session'));
+
+ expect(session.user.name).toEqual('root');
+
+ expect(session.user.jwt).toEqual('myToken');
+
+ expect(testHostComponent.loggedIn).toBeTruthy();
+
+ });
+ });
+
+ describe('Logout', () => {
+ beforeEach(() => {
+ const dspConnSpy = TestBed.inject(DspApiConnectionToken);
+
+ (dspConnSpy.v2.auth as jasmine.SpyObj).logout.and.callFake(
+ () => {
+ const response: LogoutResponse = new LogoutResponse();
+
+ response.status = 0;
+
+ return of(ApiResponseData.fromAjaxResponse({ response } as AjaxResponse));
+ }
+ );
+ });
+
+ it('should log the user out', () => {
+ const dspConnSpy = TestBed.inject(DspApiConnectionToken);
+
+ // mock session directly instead of calling the login method
+ const session: Session = {
+ id: 12345,
+ user: {
+ name: 'username',
+ jwt: 'myToken',
+ lang: 'en',
+ sysAdmin: false,
+ projectAdmin: []
+ }
+ };
+
+ // store session in localStorage
+ localStorage.setItem('session', JSON.stringify(session));
+
+ expect(JSON.parse(localStorage.getItem('session')).user.name).toEqual('username');
+
+ testHostComponent.loginFormComponent.logout();
+
+ expect(dspConnSpy.v2.auth.logout).toHaveBeenCalledTimes(1);
+
+ expect(JSON.parse(localStorage.getItem('session'))).toBeNull();
+
+ expect(testHostComponent.loggedIn).toBeFalsy();
+
+ });
+ });
+});
diff --git a/src/app/main/action/login-form/login-form.component.ts b/src/app/main/action/login-form/login-form.component.ts
new file mode 100644
index 0000000000..0976402b26
--- /dev/null
+++ b/src/app/main/action/login-form/login-form.component.ts
@@ -0,0 +1,201 @@
+import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { ApiResponseData, ApiResponseError, KnoraApiConnection, LoginResponse, LogoutResponse } from '@dasch-swiss/dsp-js';
+import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens';
+import { NotificationService } from '../../services/notification.service';
+import { Session, SessionService } from '../../services/session.service';
+
+@Component({
+ selector: 'app-login-form',
+ templateUrl: './login-form.component.html',
+ styleUrls: ['./login-form.component.scss']
+})
+export class LoginFormComponent implements OnInit {
+
+ /**
+ * navigate to the defined url (or path) after successful login
+ *
+ * @param navigate
+ */
+ @Input() navigate?: string;
+
+ /**
+ * set your theme color here,
+ * it will be used in the progress-indicator and the buttons
+ *
+ * @param color
+ */
+ @Input() color?: string;
+
+ /**
+ * set whether or not you want icons to display in the input fields
+ *
+ * @param icons
+ */
+ @Input() icons?: boolean;
+
+ /**
+ * emits true when the login process was successful and false in case of error
+ *
+ * @param loginSuccess
+ *
+ */
+ @Output() loginSuccess: EventEmitter = new EventEmitter();
+
+ /**
+ * emits true when the logout process was successful and false in case of error
+ *
+ * @param logoutSuccess
+ *
+ */
+ @Output() logoutSuccess: EventEmitter = new EventEmitter();
+
+ // is there already a valid session?
+ session: Session;
+
+ // form
+ form: FormGroup;
+
+ // show progress indicator
+ loading = false;
+
+ isError: boolean;
+
+ // specific error messages
+ loginFailed = false;
+ loginErrorServer = false;
+
+ // labels for the login form
+ // todo: should be handled by translation service (i18n)
+ formLabel = {
+ title: 'Login here',
+ name: 'Username',
+ pw: 'Password',
+ submit: 'Login',
+ retry: 'Retry',
+ logout: 'LOGOUT',
+ remember: 'Remember me',
+ forgotPassword: 'Forgot password?',
+ error: {
+ failed: 'Password or username is wrong',
+ server: 'An error has occurred when connecting to the server. Try again later or contact the DaSCH support.'
+ }
+ };
+
+ // error definitions for the following form fields
+ formErrors = {
+ username: '',
+ password: ''
+ };
+
+ // error messages for the form fields defined in formErrors
+ validationMessages = {
+ username: {
+ required: 'user name is required.'
+ },
+ password: {
+ required: 'password is required'
+ }
+ };
+
+
+ constructor(
+ @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection,
+ private _notification: NotificationService,
+ private _sessionService: SessionService,
+ private _fb: FormBuilder
+ ) { }
+
+ ngOnInit() {
+ // if session is valid (a user is logged-in) show a message, otherwise build the form
+ this._sessionService.isSessionValid().subscribe(
+ result => {
+ // returns a result if session is still valid
+ if (result) {
+ this.session = JSON.parse(localStorage.getItem('session'));
+ } else {
+ // session is invalid, build the login form
+ this.buildLoginForm();
+ }
+ }
+ );
+ }
+
+ buildLoginForm(): void {
+ this.form = this._fb.group({
+ username: ['', Validators.required],
+ password: ['', Validators.required]
+ });
+ }
+
+ /**
+ * @ignore
+ *
+ * Login and set session
+ */
+ login() {
+
+ this.loading = true;
+ this.isError = false;
+
+ // grab values from form
+ const identifier = this.form.get('username').value;
+ const password = this.form.get('password').value;
+
+ const identifierType: 'iri' | 'email' | 'username' = (identifier.indexOf('@') > -1 ? 'email' : 'username');
+
+ this._dspApiConnection.v2.auth.login(identifierType, identifier, password).subscribe(
+ (response: ApiResponseData) => {
+ this._sessionService.setSession(response.body.token, identifier, identifierType).subscribe(
+ () => {
+ this.session = this._sessionService.getSession();
+ this.loginSuccess.emit(true);
+ this.loading = false;
+ }
+ );
+ },
+ (error: ApiResponseError) => {
+ // error handling
+ this.loginFailed = (error.status === 401 || error.status === 404);
+ this.loginErrorServer = (error.status === 0 || error.status >= 500 && error.status < 600);
+
+ if (this.loginErrorServer) {
+ this._notification.openSnackBar(error);
+ }
+
+ this.loginSuccess.emit(false);
+ this.isError = true;
+
+ this.loading = false;
+ }
+ );
+ }
+
+ /**
+ * @ignore
+ *
+ * Logout and destroy session
+ *
+ */
+ logout() {
+ this.loading = true;
+
+ this._dspApiConnection.v2.auth.logout().subscribe(
+ (response: ApiResponseData) => {
+ this.logoutSuccess.emit(true);
+ this._sessionService.destroySession();
+ this.loading = false;
+ this.buildLoginForm();
+ this.session = undefined;
+ this.form.get('password').setValue('');
+ },
+ (error: ApiResponseError) => {
+ this._notification.openSnackBar(error);
+ this.logoutSuccess.emit(false);
+ this.loading = false;
+ }
+ );
+
+ }
+
+}
diff --git a/src/app/main/action/message/message.component.html b/src/app/main/action/message/message.component.html
new file mode 100644
index 0000000000..7f9a41cd87
--- /dev/null
+++ b/src/app/main/action/message/message.component.html
@@ -0,0 +1,61 @@
+
+
+
+ {{message?.type | uppercase }} {{message?.status}} | {{message?.statusMsg}}
+
+
+
+
+
+
+
+
+ → {{message?.route}}
+
+
+
+
+
+
+ {{apiError.error.toString()}}
+
+
+
+
+ {{links.title}}
+
+ {{item.icon}}
+ {{item.label}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{message?.statusText}}
+
+
+
+
+
diff --git a/src/app/main/action/message/message.component.scss b/src/app/main/action/message/message.component.scss
new file mode 100644
index 0000000000..f45b4cd4c8
--- /dev/null
+++ b/src/app/main/action/message/message.component.scss
@@ -0,0 +1,110 @@
+@import '../../../../assets/style/responsive';
+
+$accent: rgba(255, 196, 0, 1);
+$primary: rgba(0, 105, 92, 1);
+$warn: rgb(244, 67, 54);
+
+.app-panel {
+ display: flex;
+ box-sizing: border-box;
+ flex-direction: row;
+ white-space: nowrap;
+}
+
+.fill-remaining-space {
+ flex-basis: auto;
+ flex-grow: 1;
+ flex-shrink: 1;
+}
+
+.app-error {
+ background-color: rgba($warn, .5);
+}
+
+.app-warning {
+ background-color: rgba($accent, .5);
+}
+
+.app-note,
+.app-hint {
+ background-color: $primary;
+ color: #fff;
+}
+
+.app-error,
+.app-warning,
+.app-note,
+.app-hint {
+ margin: 12px auto;
+ max-width: 640px;
+
+ .message-subtitle {
+ padding-bottom: 12px;
+
+ .left {
+ float: left;
+ left: 16px;
+ position: absolute;
+ text-align: left;
+ }
+
+ .right {
+ float: right;
+ right: 16px;
+ position: absolute;
+ text-align: right;
+
+ }
+ }
+
+ .message-title {
+ padding-top: 12px;
+
+ }
+
+ .message-content {
+ margin-bottom: 48px;
+ margin-top: 48px;
+
+ .link {
+ cursor: pointer;
+ }
+ }
+
+ .message-footnote {
+ padding: 24px;
+
+ .bolder {
+ font-weight: bolder;
+ }
+
+ .action {
+ margin: 48px auto 0 auto;
+ display: table;
+ }
+ }
+}
+
+.app-short-message {
+
+ .app-short-message-text {
+ font-weight: bolder;
+ text-align: center;
+ }
+
+}
+
+
+// mobile device: phone
+@media (max-width: map-get($grid-breakpoints, phone)) {
+
+ .app-panel {
+ white-space: normal;
+ }
+
+ .app-short-message {
+ .app-short-message-text {
+ text-align: left;
+ }
+ }
+}
diff --git a/src/app/main/action/message/message.component.spec.ts b/src/app/main/action/message/message.component.spec.ts
new file mode 100644
index 0000000000..699956e37e
--- /dev/null
+++ b/src/app/main/action/message/message.component.spec.ts
@@ -0,0 +1,218 @@
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { waitForAsync, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
+import { MatCardModule } from '@angular/material/card';
+import { MatIconModule } from '@angular/material/icon';
+import { MatListModule } from '@angular/material/list';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ApiResponseError } from '@dasch-swiss/dsp-js';
+import { StatusMsg } from 'src/assets/http/statusMsg';
+import { AppMessageData, MessageComponent } from './message.component';
+
+/**
+ * test host component to simulate parent component.
+ */
+@Component({
+ template: `
+ `
+})
+class ShortMessageTestHostComponent implements OnInit {
+
+ @ViewChild('message', { static: false }) messageComponent: MessageComponent;
+
+ shortMessage: AppMessageData = {
+ status: 200,
+ statusMsg: 'Success',
+ statusText: 'You just updated the user profile.',
+ type: 'Note',
+ footnote: 'Close it'
+ };
+
+ size = 'short';
+
+ constructor() {
+ }
+
+ ngOnInit() { }
+}
+
+/**
+ * test host component to simulate parent component.
+ */
+@Component({
+ template: ''
+})
+class LongMessageTestHostComponent implements OnInit {
+
+ @ViewChild('message', { static: false }) messageComponent: MessageComponent;
+
+ errorMessage: ApiResponseError = {
+ status: 403,
+ url: 'http://0.0.0.0:3333/admin/projects/shortcode/001/members',
+ method: 'Http failure response for http://0.0.0.0:3333/admin/projects/shortcode/001/members: 400 Bad Request',
+ error: 'error message'
+ };
+
+ constructor() {
+ }
+
+ ngOnInit() { }
+}
+
+/**
+ * test host component to simulate parent component.
+ */
+@Component({
+ template: ''
+})
+class ShortMessageWithDurationTestHostComponent implements OnInit {
+
+ @ViewChild('message', { static: false }) messageComponent: MessageComponent;
+
+ shortMessage: AppMessageData = {
+ status: 200,
+ statusMsg: 'Success',
+ statusText: 'You just updated the user profile.',
+ type: 'Note',
+ footnote: 'Close it'
+ };
+
+ size = 'short';
+
+ constructor() {
+ }
+
+ ngOnInit() { }
+}
+
+describe('MessageComponent', () => {
+ let shortMsgTestHostComponent: ShortMessageTestHostComponent;
+ let shortMsgTestHostFixture: ComponentFixture;
+
+ let longMsgTestHostComponent: LongMessageTestHostComponent;
+ let longMsgTestHostFixture: ComponentFixture;
+
+ let shortMsgDurationTestHostComponent: ShortMessageWithDurationTestHostComponent;
+ let shortMsgDurationTestHostFixture: ComponentFixture;
+
+ let status: StatusMsg;
+ let apiResonseError: ApiResponseError;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ MatCardModule,
+ MatIconModule,
+ MatListModule,
+ RouterTestingModule
+ ],
+ providers: [
+ StatusMsg,
+ ApiResponseError
+ ],
+ declarations: [
+ MessageComponent,
+ ShortMessageTestHostComponent,
+ LongMessageTestHostComponent,
+ ShortMessageWithDurationTestHostComponent
+ ]
+ }).compileComponents();
+
+ status = TestBed.inject(StatusMsg);
+ apiResonseError = TestBed.inject(ApiResponseError);
+
+ }));
+
+ describe('display a short message', () => {
+ beforeEach(() => {
+ shortMsgTestHostFixture = TestBed.createComponent(ShortMessageTestHostComponent);
+ shortMsgTestHostComponent = shortMsgTestHostFixture.componentInstance;
+ shortMsgTestHostFixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(shortMsgTestHostComponent.messageComponent).toBeTruthy();
+ });
+
+ it('should display a short message', () => {
+ expect(shortMsgTestHostComponent.messageComponent).toBeTruthy();
+ expect(shortMsgTestHostComponent.messageComponent.message.status).toEqual(200);
+ expect(shortMsgTestHostComponent.messageComponent.message.statusMsg).toEqual('Success');
+
+ const hostCompDe = shortMsgTestHostFixture.debugElement;
+
+ const messageEl = hostCompDe.query(By.directive(MessageComponent));
+
+ const spanShortMessageElement = messageEl.query(By.css('.app-short-message-text'));
+
+ expect(spanShortMessageElement.nativeElement.innerText).toEqual('You just updated the user profile.');
+
+ });
+
+ });
+
+ describe('display a long message', () => {
+ beforeEach(() => {
+ longMsgTestHostFixture = TestBed.createComponent(LongMessageTestHostComponent);
+ longMsgTestHostComponent = longMsgTestHostFixture.componentInstance;
+ longMsgTestHostFixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(longMsgTestHostComponent.messageComponent).toBeTruthy();
+ });
+
+ it('should display a long message', () => {
+ expect(longMsgTestHostComponent.messageComponent).toBeTruthy();
+
+ expect(longMsgTestHostComponent.messageComponent.message.status).toEqual(403);
+ expect(longMsgTestHostComponent.messageComponent.message.statusMsg).toEqual('Forbidden');
+ expect(longMsgTestHostComponent.messageComponent.message.statusText).toEqual(
+ 'The request was a legal request, but the server is refusing to respond to it');
+
+ const hostCompDe = longMsgTestHostFixture.debugElement;
+
+ const messageEl = hostCompDe.query(By.directive(MessageComponent));
+
+ const messageSubtitleElement = messageEl.query(By.css('.message-subtitle .left'));
+
+ expect(messageSubtitleElement.nativeElement.innerText).toEqual('ERROR 403 | Forbidden');
+
+ const messageTitleElement = messageEl.query(By.css('.message-title'));
+
+ expect(messageTitleElement.nativeElement.innerText).toEqual(
+ 'The request was a legal request, but the server is refusing to respond to it');
+
+ });
+ });
+
+ describe('display a short message with a duration of 2 seconds', () => {
+ beforeEach(() => {
+ shortMsgDurationTestHostFixture = TestBed.createComponent(ShortMessageWithDurationTestHostComponent);
+ shortMsgDurationTestHostComponent = shortMsgDurationTestHostFixture.componentInstance;
+ shortMsgDurationTestHostFixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(shortMsgDurationTestHostComponent.messageComponent).toBeTruthy();
+ });
+
+ it('should display a short message', fakeAsync(() => {
+ expect(shortMsgDurationTestHostComponent.messageComponent).toBeTruthy();
+ expect(shortMsgDurationTestHostComponent.messageComponent.message.status).toEqual(200);
+ expect(shortMsgDurationTestHostComponent.messageComponent.message.statusMsg).toEqual('Success');
+
+ const hostCompDe = shortMsgDurationTestHostFixture.debugElement;
+
+ const messageEl = hostCompDe.query(By.directive(MessageComponent));
+
+ const spanShortMessageElement = messageEl.query(By.css('.app-short-message-text'));
+
+ expect(spanShortMessageElement.nativeElement.innerText).toEqual('You just updated the user profile.');
+
+ shortMsgDurationTestHostFixture.whenStable().then(() => {
+ expect(shortMsgDurationTestHostComponent.messageComponent.disable).toBeTruthy();
+ });
+ }));
+ });
+});
diff --git a/src/app/main/action/message/message.component.ts b/src/app/main/action/message/message.component.ts
new file mode 100644
index 0000000000..90083f5c5c
--- /dev/null
+++ b/src/app/main/action/message/message.component.ts
@@ -0,0 +1,233 @@
+import { Location } from '@angular/common';
+import { Component, Input, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { ApiResponseError } from '@dasch-swiss/dsp-js';
+import { StatusMsg } from 'src/assets/http/statusMsg';
+
+/**
+ * @ignore
+ * Data type for messages
+ */
+export class AppMessageData {
+ status: number;
+ statusMsg?: string;
+ statusText?: string;
+ type?: string;
+ route?: string;
+ footnote?: string;
+ errorInfo?: string;
+ url?: string;
+}
+
+/**
+ * @deprecated Will be replaced by notification service with material snackbar
+ */
+@Component({
+ selector: 'app-message',
+ templateUrl: './message.component.html',
+ styleUrls: ['./message.component.scss']
+})
+export class MessageComponent implements OnInit {
+
+ /**
+ * message type: AppMessageData
+ *
+ * @param message This type needs at least a status number (0-599).
+ * In this case, or if type is ApiResponseError, it takes the default status messages
+ * from the list of HTTP status codes (https://en.wikipedia.org/wiki/List_of_HTTP_status_codes)
+ */
+ @Input() message: AppMessageData = new AppMessageData();
+
+ /**
+ * message type: ApiResponseError
+ * @param apiError
+ */
+ @Input() apiError?: ApiResponseError;
+
+ /**
+ * size of the message: long, medium or short?
+ * @param size Default size is 'long'
+ */
+ @Input() size: 'short' | 'medium' | 'long' = 'long';
+
+ /**
+ * @deprecated
+ * @param short Show short message only
+ * A small message box to notify the user an event has occured.
+ */
+ @Input() short = (this.size === 'short');
+
+ /**
+ * @deprecated
+ * @param medium Show medium message
+ * A message box without footnote or links.
+ */
+ @Input() medium = (this.size === 'medium');
+
+ /**
+ * @param duration How long should the message be displayed
+ */
+ @Input() duration?: number;
+
+ statusMsg: any;
+
+ isLoading = true;
+
+ showLinks = false;
+
+ // disable message
+ disable = false;
+
+ /**
+ * @ignore
+ * default link list, which will be used in message content to give a user some possibilities
+ * what he can do in the case of an error
+ *
+ */
+ links: any = {
+ title: 'You have the following possibilities now',
+ list: [
+ {
+ label: 'go to the start page',
+ route: '/',
+ icon: 'keyboard_arrow_right'
+ },
+ {
+ label: 'try to login',
+ route: '/login',
+ icon: 'keyboard_arrow_right'
+ },
+ {
+ label: 'go back',
+ route: '<--',
+ icon: 'keyboard_arrow_left'
+ }
+ ]
+ };
+
+ constructor(
+ private _router: Router,
+ private _location: Location,
+ private _activatedRoute: ActivatedRoute,
+ private _status: StatusMsg
+ ) { }
+
+ ngOnInit() {
+ // temporary solution as long we have to support the deprecated inputs "short" and "medium"
+ if (this.short || this.medium) {
+ this.size = (this.short ? 'short' : 'medium');
+ }
+
+
+ if (this.apiError) {
+ this.message.status = this.apiError.status;
+ }
+
+ this.statusMsg = this._status.default;
+
+ if (!this.message) {
+ this._activatedRoute.data.subscribe((data: any) => {
+ this.message.status = data.status;
+ });
+ }
+
+ this.message = this.setMessage(this.message);
+ this.isLoading = false;
+ if (this.duration) {
+ setTimeout(() => this.disable = true, this.duration);
+ }
+ }
+
+ setMessage(msg: AppMessageData) {
+ const tmpMsg: AppMessageData = {} as AppMessageData;
+
+ const s: number = msg.status === 0 ? 503 : msg.status;
+
+ tmpMsg.status = s;
+ tmpMsg.route = msg.route;
+ tmpMsg.statusMsg = msg.statusMsg;
+ tmpMsg.statusText = msg.statusText;
+ tmpMsg.route = msg.route;
+ tmpMsg.footnote = msg.footnote;
+
+ switch (true) {
+ case s > 0 && s < 300:
+ // the message is a note
+ tmpMsg.type = 'note';
+ tmpMsg.statusMsg =
+ msg.statusMsg !== undefined
+ ? msg.statusMsg
+ : this.statusMsg[s].message;
+ tmpMsg.statusText =
+ msg.statusText !== undefined
+ ? msg.statusText
+ : this.statusMsg[s].description;
+ // console.log('the message is a note');
+ break;
+ case s >= 300 && s < 400:
+ // the message is a warning
+ tmpMsg.type = 'warning';
+ tmpMsg.statusMsg =
+ msg.statusMsg !== undefined
+ ? msg.statusMsg
+ : this.statusMsg[s].message;
+ tmpMsg.statusText =
+ msg.statusText !== undefined
+ ? msg.statusText
+ : this.statusMsg[s].description;
+ // console.log('the message is a warning');
+
+ break;
+ case s >= 400 && s < 500:
+ // the message is a client side (app) error
+ // console.error('the message is a client side (app) error', s);
+ tmpMsg.type = 'error';
+ tmpMsg.statusMsg =
+ msg.statusMsg !== undefined
+ ? msg.statusMsg
+ : this.statusMsg[s].message;
+ tmpMsg.statusText =
+ msg.statusText !== undefined
+ ? msg.statusText
+ : this.statusMsg[s].description;
+ this.showLinks = (this.size === 'long');
+ break;
+ case s >= 500 && s < 600:
+ // the message is a server side (api) error
+ // console.error('the message is a server side (api) error');
+ tmpMsg.type = 'error';
+ tmpMsg.statusMsg =
+ msg.statusMsg !== undefined
+ ? msg.statusMsg
+ : this.statusMsg[s].message;
+ tmpMsg.statusText =
+ msg.statusText !== undefined
+ ? msg.statusText
+ : this.statusMsg[s].description;
+ this.showLinks = false;
+ break;
+ default:
+ // no default configuration?
+ break;
+ }
+
+ return tmpMsg;
+ }
+
+ goToLocation(route: string) {
+ if (route === '<--') {
+ this._location.back();
+ } else {
+ this._router.navigate([route]);
+ }
+ }
+
+ closeMessage() {
+ this.disable = !this.disable;
+ }
+
+ reload() {
+ window.location.reload();
+ }
+
+}
diff --git a/src/app/main/action/progress-indicator/progress-indicator.component.html b/src/app/main/action/progress-indicator/progress-indicator.component.html
new file mode 100644
index 0000000000..847cc13cc1
--- /dev/null
+++ b/src/app/main/action/progress-indicator/progress-indicator.component.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+ keyboard_arrow_right
+
+ done
+
+ not_interested
+
+
+
+
+
+
+
+
diff --git a/src/app/main/action/progress-indicator/progress-indicator.component.scss b/src/app/main/action/progress-indicator/progress-indicator.component.scss
new file mode 100644
index 0000000000..52168ece0b
--- /dev/null
+++ b/src/app/main/action/progress-indicator/progress-indicator.component.scss
@@ -0,0 +1,179 @@
+// --------------------------------------------------------------
+// configuration TODO: we should implement this from the app!
+// --------------------------------------------------------------
+
+// color definitions
+$bright: rgba(249, 249, 249, 1);
+$dark: rgba(41, 41, 41, 1);
+
+$mute: rgba(128, 128, 128, 0.8);
+
+$accent: rgba(255, 196, 0, 1);
+$primary: rgba(0, 105, 92, 1);
+$warn: rgb(244, 67, 54);
+
+// --------------------------------------------------------------
+
+//
+// two lines with three bouncing squares each
+//
+
+.app-progress-indicator.default {
+ height: 56px;
+ margin-left: auto;
+ margin-right: auto;
+ padding: 24px 36px;
+ top: 60px;
+ width: 96px;
+
+ &.page-center {
+ left: 50%;
+ position: absolute;
+ top: 39%;
+ transform: translate(-50%, -50%);
+ }
+
+ p,
+ h1,
+ h2,
+ h3 {
+ color: #555555;
+ text-align: center;
+ }
+
+ .line {
+ margin: 0 auto;
+ text-align: center;
+ width: 70px;
+
+ > div {
+ animation: bounce-keyframes 1.4s infinite ease-in-out both;
+ background-color: $primary;
+ border-radius: 6px;
+ display: inline-block;
+ height: 18px;
+ width: 18px;
+ }
+
+ .bounce1 {
+ animation-delay: -.32s;
+ }
+
+ .bounce2 {
+ animation-delay: -.16s;
+ }
+ }
+
+ @keyframes bounce-keyframes {
+
+ 0%, 80%, 100% {
+ transform: scale(0);
+ }
+
+ 40% {
+ transform: scale(1);
+ }
+ }
+}
+
+//
+// submit data has a different progress indicator
+//
+.app-progress-indicator.submit {
+ height: 32px;
+ width: 32px;
+
+ .on-submit {
+ animation: spinner-keyframes .7s linear infinite;
+ height: 32px;
+ width: 32px;
+
+ .spinner {
+ border: 2px solid $primary;
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ border-right-color: transparent;
+ height: 28px;
+ width: 28px;
+ }
+ }
+
+ .before-submit {
+ color: $mute;
+ }
+
+ .after-submit {
+ color: $primary;
+ }
+
+ .submit-error {
+ color: $warn;
+ }
+
+ @keyframes spinner-keyframes {
+ 0% { transform: rotate(0deg); transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); transform: rotate(360deg); }
+ }
+
+}
+
+//
+// loader element
+//
+.loading-progress-indicator {
+ text-align: center;
+ width: 100%;
+
+ .text {
+ color: $primary;
+ font-size: 12pt;
+ }
+
+ .dot {
+ animation: dot-keyframes 1.4s infinite ease-in-out;
+ background-color: $primary;
+ border-radius: 2px;
+ display: inline-block;
+ height: 6px;
+ margin: 3px 6px 2px;
+ width: 6px;
+
+ &:nth-child(2) {
+ animation-delay: .16s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: .32s;
+ }
+
+ &:nth-child(4) {
+ animation-delay: .48s;
+ }
+
+ &:nth-child(5) {
+ animation-delay: .64s;
+ }
+
+ &:nth-child(6) {
+ animation-delay: .8s;
+ }
+ }
+}
+
+@keyframes dot-keyframes {
+ 0% {
+ opacity: .4;
+ transform: scale(1, 1);
+ }
+
+ 50% {
+ opacity: 1;
+ transform: scale(1.2, 1.2);
+ }
+
+ 100% {
+ opacity: .4;
+ transform: scale(1, 1);
+ }
+}
+
diff --git a/src/app/main/action/progress-indicator/progress-indicator.component.spec.ts b/src/app/main/action/progress-indicator/progress-indicator.component.spec.ts
new file mode 100644
index 0000000000..ecf2c1343d
--- /dev/null
+++ b/src/app/main/action/progress-indicator/progress-indicator.component.spec.ts
@@ -0,0 +1,165 @@
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { MatIconModule } from '@angular/material/icon';
+import { By } from '@angular/platform-browser';
+import { ProgressIndicatorComponent } from './progress-indicator.component';
+
+/**
+ * test host component to simulate parent component with a progress bar.
+ */
+@Component({
+ template: `
+ `
+})
+class TestHostComponent implements OnInit {
+
+ @ViewChild('progressIndicator', { static: false }) progressIndicatorComponent: ProgressIndicatorComponent;
+
+ status = 0;
+ color = 'red';
+
+ constructor() {
+ }
+
+ ngOnInit() { }
+}
+
+describe('ProgressIndicatorComponent', () => {
+ let testHostComponent: TestHostComponent;
+ let testHostFixture: ComponentFixture;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ MatIconModule
+ ],
+ declarations: [
+ ProgressIndicatorComponent,
+ TestHostComponent
+ ]
+ })
+ .compileComponents();
+ }));
+
+
+ beforeEach(() => {
+ testHostFixture = TestBed.createComponent(TestHostComponent);
+ testHostComponent = testHostFixture.componentInstance;
+ testHostFixture.detectChanges();
+
+ expect(testHostComponent).toBeTruthy();
+ });
+
+ it('should create', () => {
+ expect(testHostComponent.progressIndicatorComponent).toBeTruthy();
+ });
+
+ it('should display a red spinner as progress indicator', () => {
+ expect(testHostComponent.progressIndicatorComponent).toBeTruthy();
+ expect(testHostComponent.progressIndicatorComponent.color).toEqual('red');
+ expect(testHostComponent.progressIndicatorComponent.status).toEqual(0);
+
+ const hostCompDe = testHostFixture.debugElement;
+
+ const progressEl = hostCompDe.query(By.directive(ProgressIndicatorComponent));
+
+ const divProgressElement = progressEl.query(By.css('.app-progress-indicator'));
+
+ const submitEl = divProgressElement.query(By.css('div'));
+
+ const spinnerEl = submitEl.query(By.css('div'));
+
+ expect(spinnerEl.styles.borderTopColor).toEqual('red');
+ expect(spinnerEl.styles.borderLeftColor).toEqual('red');
+
+ });
+
+ it('should change the color of the progress indicator from red to blue', () => {
+ expect(testHostComponent.progressIndicatorComponent).toBeTruthy();
+ expect(testHostComponent.progressIndicatorComponent.color).toEqual('red');
+ expect(testHostComponent.progressIndicatorComponent.status).toEqual(0);
+
+ const hostCompDe = testHostFixture.debugElement;
+
+ const progressEl = hostCompDe.query(By.directive(ProgressIndicatorComponent));
+
+ const divProgressElement = progressEl.query(By.css('.app-progress-indicator'));
+
+ const submitEl = divProgressElement.query(By.css('div'));
+
+ const spinnerEl = submitEl.query(By.css('div'));
+
+ expect(spinnerEl.styles.borderTopColor).toEqual('red');
+ expect(spinnerEl.styles.borderLeftColor).toEqual('red');
+
+ // change the color of the spinner
+ testHostComponent.progressIndicatorComponent.color = 'blue';
+
+ testHostFixture.detectChanges();
+
+ // expect the spinner to be blue
+ expect(spinnerEl.styles.borderTopColor).toEqual('blue');
+ expect(spinnerEl.styles.borderLeftColor).toEqual('blue');
+ });
+
+ it('should update the progress indicator according to the status value', () => {
+ expect(testHostComponent.progressIndicatorComponent).toBeTruthy();
+ expect(testHostComponent.progressIndicatorComponent.color).toEqual('red');
+ expect(testHostComponent.progressIndicatorComponent.status).toEqual(0);
+
+ const hostCompDe = testHostFixture.debugElement;
+
+ const progressEl = hostCompDe.query(By.directive(ProgressIndicatorComponent));
+
+ const divProgressElement = progressEl.query(By.css('.app-progress-indicator'));
+
+ const submitEl = divProgressElement.query(By.css('div'));
+
+ const spinnerEl = submitEl.query(By.css('div'));
+
+ expect(spinnerEl.attributes.class).toEqual('spinner');
+
+ // update status value to 1
+ testHostComponent.progressIndicatorComponent.status = 1;
+
+ testHostFixture.detectChanges();
+
+ const divEl = divProgressElement.query(By.css('div'));
+
+ const matIconEl = divProgressElement.query(By.css('mat-icon'));
+
+ // new status: done
+ expect(matIconEl.attributes.class).toEqual('mat-icon notranslate after-submit material-icons mat-icon-no-color');
+ expect(matIconEl.nativeElement.innerText).toEqual('done');
+ expect(matIconEl.styles.color).toEqual('red');
+ });
+
+ it('should display the default progress indicator when the status is undefined', () => {
+ expect(testHostComponent.progressIndicatorComponent).toBeTruthy();
+ expect(testHostComponent.progressIndicatorComponent.color).toEqual('red');
+ expect(testHostComponent.progressIndicatorComponent.status).toEqual(0);
+
+ // change the status to undefined
+ testHostComponent.progressIndicatorComponent.status = undefined;
+
+ expect(testHostComponent.progressIndicatorComponent.status).toEqual(undefined);
+
+ testHostFixture.detectChanges();
+
+ const hostCompDe = testHostFixture.debugElement;
+
+ const progressEl = hostCompDe.query(By.directive(ProgressIndicatorComponent));
+
+ const divProgressElement = progressEl.query(By.css('.app-progress-indicator'));
+
+ const lineEl = divProgressElement.query(By.css('.line'));
+
+ const bounceEl = lineEl.query(By.css('div'));
+
+ // expect the default progress indicator
+ expect(bounceEl.styles.backgroundColor).toEqual('red');
+ expect(bounceEl.attributes.class).toEqual('bounce1');
+
+ });
+});
diff --git a/src/app/main/action/progress-indicator/progress-indicator.component.ts b/src/app/main/action/progress-indicator/progress-indicator.component.ts
new file mode 100644
index 0000000000..0023a3a363
--- /dev/null
+++ b/src/app/main/action/progress-indicator/progress-indicator.component.ts
@@ -0,0 +1,42 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+@Component({
+ selector: 'app-progress-indicator',
+ templateUrl: './progress-indicator.component.html',
+ styleUrls: ['./progress-indicator.component.scss']
+})
+export class ProgressIndicatorComponent implements OnInit {
+
+ /**
+ * @param status number relating to status
+ *
+ * [status] is a number and can be used when submitting form data:
+ *
+ * - not ready: -1
+ * - loading: 0
+ * - done: 1
+ *
+ * - error: 400
+ */
+ @Input() status?: number;
+
+ /**
+ * @param color Hex value or predefined color from scss
+ *
+ * Parameter to customize the appearance of the loader.
+ * Hexadecimal color value e.g. #00ff00 or similar color values 'red', 'green' etc.
+ *
+ * TODO: Default color should come from app settings
+ */
+ @Input() color = '#5849a7';
+
+ /**
+ * @ignore
+ */
+ constructor() {
+ }
+
+ ngOnInit() {
+ }
+
+}
diff --git a/src/app/main/action/selected-resources/selected-resources.component.html b/src/app/main/action/selected-resources/selected-resources.component.html
new file mode 100644
index 0000000000..f681553a1c
--- /dev/null
+++ b/src/app/main/action/selected-resources/selected-resources.component.html
@@ -0,0 +1,20 @@
+
+
+
Number of resources selected: {{ resCount }}
+
+
Ids of the Selected resources are:
+
+
+ description
+ {{ id }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/main/action/selected-resources/selected-resources.component.scss b/src/app/main/action/selected-resources/selected-resources.component.scss
new file mode 100644
index 0000000000..2e8c1a55d7
--- /dev/null
+++ b/src/app/main/action/selected-resources/selected-resources.component.scss
@@ -0,0 +1,37 @@
+
+.selected-resources {
+ margin-top: 10px;
+
+ .mat-list-item {
+ height: auto;
+
+ .mat-icon {
+ font-size: 20px;
+ color: #673ab7;
+ }
+
+ .mat-line {
+ white-space: normal !important;
+ }
+
+ .mat-list-item {
+ margin-left: 0px;
+ }
+ }
+}
+
+.res-ids-container {
+ margin: 8px;
+}
+
+.res-value {
+ color: #673ab7;
+}
+
+.action-buttons {
+margin-top: 20px;
+
+.res-action {
+ margin: 4px;
+}
+}
diff --git a/src/app/main/action/selected-resources/selected-resources.component.spec.ts b/src/app/main/action/selected-resources/selected-resources.component.spec.ts
new file mode 100644
index 0000000000..836a9e8d69
--- /dev/null
+++ b/src/app/main/action/selected-resources/selected-resources.component.spec.ts
@@ -0,0 +1,62 @@
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { Component, ViewChild } from '@angular/core';
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatIconModule } from '@angular/material/icon';
+import { MatButtonModule } from '@angular/material/button';
+import { MatListModule } from '@angular/material/list';
+
+import { SelectedResourcesComponent } from './selected-resources.component';
+
+/**
+ * test host component to simulate parent component.
+ */
+@Component({
+ selector: 'app-selected-resources-host-component',
+ template: `
+
+ `
+})
+class TestHostSelectedResourcesComponent {
+
+ @ViewChild('selectedResources') selectedResources: SelectedResourcesComponent;
+
+ selectedResCount = 2;
+ selectedRes = ['http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw', 'http://rdfh.ch/0010/H6gBWUuJSuuO-CilddsgfdQw'];
+
+ getActionType(action: string) {
+ console.log(action);
+ }
+}
+
+describe('SelectedResourcesComponent', () => {
+ let testHostComponent: TestHostSelectedResourcesComponent;
+ let testHostFixture: ComponentFixture;
+ let loader: HarnessLoader;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ SelectedResourcesComponent,
+ TestHostSelectedResourcesComponent
+ ],
+ imports: [
+ MatListModule,
+ MatButtonModule,
+ MatIconModule
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ testHostFixture = TestBed.createComponent(TestHostSelectedResourcesComponent);
+ testHostComponent = testHostFixture.componentInstance;
+ loader = TestbedHarnessEnvironment.loader(testHostFixture);
+ testHostFixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(testHostComponent.selectedResources).toBeTruthy();
+ });
+});
diff --git a/src/app/main/action/selected-resources/selected-resources.component.ts b/src/app/main/action/selected-resources/selected-resources.component.ts
new file mode 100644
index 0000000000..3b724582d1
--- /dev/null
+++ b/src/app/main/action/selected-resources/selected-resources.component.ts
@@ -0,0 +1,36 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+
+@Component({
+ selector: 'app-selected-resources',
+ templateUrl: './selected-resources.component.html',
+ styleUrls: ['./selected-resources.component.scss']
+})
+export class SelectedResourcesComponent {
+
+ // total number of resources selected
+ @Input() resCount: number;
+
+ // list of selected resources ids
+ @Input() resIds: string[];
+
+ // return selected actions and other info if any
+ @Output() actionType: EventEmitter = new EventEmitter();
+
+ // actions which can be applied on selected resources
+ resourceAction: 'compare' | 'edit' | 'delete' | 'starred' | 'cancel';
+
+ constructor() { }
+
+ // return compare action
+ compareResources() {
+ this.resourceAction = 'compare';
+ this.actionType.emit(this.resourceAction);
+ }
+
+ // cancel action
+ cancelSelection() {
+ this.resourceAction = 'cancel';
+ this.actionType.emit(this.resourceAction);
+ }
+
+}
diff --git a/src/app/main/action/sort-button/sort-button.component.html b/src/app/main/action/sort-button/sort-button.component.html
new file mode 100644
index 0000000000..621e5e323f
--- /dev/null
+++ b/src/app/main/action/sort-button/sort-button.component.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/src/app/main/action/sort-button/sort-button.component.scss b/src/app/main/action/sort-button/sort-button.component.scss
new file mode 100644
index 0000000000..d7b9d11b0b
--- /dev/null
+++ b/src/app/main/action/sort-button/sort-button.component.scss
@@ -0,0 +1,12 @@
+.active {
+ background: rgba(128,128,128,.39);
+ font-weight: bolder;
+}
+
+.right.sort {
+ float: right;
+
+ .mat-icon {
+ transform: scale(-1, 1);
+ }
+}
diff --git a/src/app/main/action/sort-button/sort-button.component.spec.ts b/src/app/main/action/sort-button/sort-button.component.spec.ts
new file mode 100644
index 0000000000..a782af9aea
--- /dev/null
+++ b/src/app/main/action/sort-button/sort-button.component.spec.ts
@@ -0,0 +1,168 @@
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MatIconModule } from '@angular/material/icon';
+import { MatMenuModule } from '@angular/material/menu';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { SortButtonComponent } from './sort-button.component';
+
+import { Component, DebugElement, OnInit, ViewChild, } from '@angular/core';
+import { By } from '@angular/platform-browser';
+import { SortingService } from '../../services/sorting.service';
+
+/**
+ * test host component to simulate parent component with a progress bar.
+ */
+@Component({
+ template: `
+
+
+
+ -
+ {{item.firstname}}
+ {{item.lastname}}
+ by
+ {{item.creator}}
+
+
+ `
+})
+class TestHostComponent implements OnInit {
+
+ @ViewChild('sortButton', { static: false }) sortButtonComponent: SortButtonComponent;
+
+ sortingService: SortingService = new SortingService();
+
+ sortProps: any = [{
+ key: 'firstname',
+ label: 'First name'
+ },
+ {
+ key: 'lastname',
+ label: 'Last name'
+ },
+ {
+ key: 'creator',
+ label: 'Creator'
+ }
+ ];
+ position = 'left';
+
+ list = [{
+ firstname: 'a',
+ lastname: 'z',
+ creator: 'André Franquin'
+
+ },
+ {
+ firstname: 'b',
+ lastname: 'y',
+ creator: 'Walt Disney'
+
+ },
+ {
+ firstname: 'c',
+ lastname: 'x',
+ creator: 'William Shakespeare'
+
+ },
+ {
+ firstname: 'd',
+ lastname: 'w',
+ creator: 'Charles M. Schulz'
+
+ }
+ ];
+
+ constructor() {
+ }
+
+ ngOnInit() { }
+
+ sortList(key) {
+ this.list = this.sortingService.keySortByAlphabetical(this.list, key);
+ }
+}
+
+describe('SortButtonComponent', () => {
+ let testHostComponent: TestHostComponent;
+ let testHostFixture: ComponentFixture;
+ const listData = [
+ { firstname: 'a', lastname: 'z', creator: 'André Franquin' },
+ { firstname: 'b', lastname: 'y', creator: 'Walt Disney' },
+ { firstname: 'c', lastname: 'x', creator: 'William Shakespeare' },
+ { firstname: 'd', lastname: 'w', creator: 'Charles M. Schulz' },
+ ];
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ MatIconModule,
+ MatMenuModule,
+ BrowserAnimationsModule
+ ],
+ declarations: [
+ SortButtonComponent,
+ TestHostComponent,
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ testHostFixture = TestBed.createComponent(TestHostComponent);
+ testHostComponent = testHostFixture.componentInstance;
+ testHostFixture.detectChanges();
+
+ expect(testHostComponent).toBeTruthy();
+ });
+
+ it('should create an instance', () => {
+ expect(testHostComponent.sortButtonComponent).toBeTruthy();
+ });
+
+ it('should sort the list by lastname', () => {
+ expect(testHostComponent.sortButtonComponent).toBeTruthy();
+ expect(testHostComponent.list).toEqual(listData);
+
+ const hostCompDe = testHostFixture.debugElement;
+
+ const spanEl: DebugElement = hostCompDe.query(By.css('span'));
+
+ // expect the button position to be 'right'
+ // expect(spanEl.properties).toEqual({ 'className': 'left' });
+
+ const sortSelectionBtnEl: DebugElement = spanEl.query(By.css('button'));
+
+ const matIconEl: DebugElement = sortSelectionBtnEl.query(By.css('mat-icon'));
+
+ // expect that the button label is 'sort'
+ expect(matIconEl.nativeElement.innerText).toEqual('sort');
+
+ // click on the sort button to trigger the sort selection menu
+ sortSelectionBtnEl.triggerEventHandler('click', null);
+
+ const matMenuEl = spanEl.query(By.css('mat-menu'));
+
+ const sortSelectionEl = matMenuEl.references.sortSelection;
+
+ // expect that items's names of the sort list are 'Firstname', 'Last name' and 'Creator'
+ expect(sortSelectionEl.items._results[0]._elementRef.nativeElement.innerText).toEqual('First name');
+ expect(sortSelectionEl.items._results[1]._elementRef.nativeElement.innerText).toEqual('Last name');
+ expect(sortSelectionEl.items._results[2]._elementRef.nativeElement.innerText).toEqual('Creator');
+
+ // sort by 'lastname' through a click event
+ const item2 = sortSelectionEl.items._results[1]._elementRef.nativeElement;
+ item2.click();
+ testHostFixture.detectChanges();
+
+
+ const listEl: DebugElement = hostCompDe.query(By.css('.list'));
+ const children = listEl.nativeNode.children;
+ expect(children[0].innerText).toEqual('d w by Charles M. Schulz');
+ expect(children[1].innerText).toEqual('c x by William Shakespeare');
+ expect(children[2].innerText).toEqual('b y by Walt Disney');
+ expect(children[3].innerText).toEqual('a z by André Franquin');
+ });
+});
+
+
diff --git a/src/app/main/action/sort-button/sort-button.component.ts b/src/app/main/action/sort-button/sort-button.component.ts
new file mode 100644
index 0000000000..7dc60a0758
--- /dev/null
+++ b/src/app/main/action/sort-button/sort-button.component.ts
@@ -0,0 +1,80 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+
+export interface SortProp {
+ key: string;
+ label: string;
+}
+
+/**
+ * a component with a list of properties to sort a list by one of them.
+ * It can be used together with the DspSortBy pipe.
+ */
+@Component({
+ selector: 'app-sort-button',
+ templateUrl: './sort-button.component.html',
+ styleUrls: ['./sort-button.component.scss']
+})
+export class SortButtonComponent implements OnInit {
+
+ /**
+ * @param sortProps[]
+ * An array of SortProp objects for the selection menu:
+ * SortProp: { key: string, label: string }
+ */
+ @Input() sortProps: SortProp[];
+
+ /**
+ * @param position string
+ * Optional position of the sort menu: right or left
+ * e.g. [position='left']
+ */
+ @Input() position: 'left' | 'right' = 'left';
+
+ /**
+ * @param icon
+ * Default icon is "sort" from material design.
+ * But you can replace it with another one
+ * e.g. sort_by_alpha
+ */
+ @Input() icon = 'sort';
+
+
+ /**
+ * @param activeKey
+ * Optional parameter: selected sort property key
+ * By default it takes the first key from sortProps
+ */
+ @Input() activeKey?: string;
+
+ /**
+ * @emits {string} sortKeyChange
+ *
+ * EventEmitter when a user selected a sort property;
+ * This is the selected key
+ */
+ @Output() sortKeyChange: EventEmitter = new EventEmitter();
+
+ menuXPos: 'before' | 'after' = 'after';
+
+ constructor() {
+ }
+
+ ngOnInit() {
+ if (this.position === 'right') {
+ this.menuXPos = 'before';
+ }
+
+ this.sortBy(this.activeKey);
+ }
+
+ /**
+ * @ignore
+ *
+ * @param key a string to sort by
+ */
+ sortBy(key: string) {
+ this.activeKey = (key ? key : this.sortProps[0].key);
+ this.sortKeyChange.emit(this.activeKey);
+ }
+
+}
diff --git a/src/app/main/action/string-literal-input/string-literal-input.component.html b/src/app/main/action/string-literal-input/string-literal-input.component.html
new file mode 100644
index 0000000000..3f9ee7e7e7
--- /dev/null
+++ b/src/app/main/action/string-literal-input/string-literal-input.component.html
@@ -0,0 +1,79 @@
+
diff --git a/src/app/main/action/string-literal-input/string-literal-input.component.scss b/src/app/main/action/string-literal-input/string-literal-input.component.scss
new file mode 100644
index 0000000000..6176307811
--- /dev/null
+++ b/src/app/main/action/string-literal-input/string-literal-input.component.scss
@@ -0,0 +1,51 @@
+.mat-form-field {
+ width: 100% !important;
+}
+
+.existing-value {
+ font-weight: bold;
+}
+
+// string literal textarea: overwrite material styling !important
+.string-literal {
+ &.short-text {
+ .mat-button.select-lang {
+ min-width: 36px !important;
+ padding: 0 8px !important;
+ border-radius: 4px 0 0 0 !important;
+ }
+ }
+
+ &.long-text {
+ display: flex;
+
+ // select language
+ .mat-button-toggle {
+ height: 36px;
+ width: 56px;
+
+ .mat-button-toggle-button {
+ width: 50px !important;
+ }
+ }
+
+ .mat-button-toggle-vertical {
+ height: calc(4 * 36px + 2px);
+ border-radius: 4px 0 0 4px !important;
+ border-right: 0px;
+ overflow-y: auto;
+
+ &::-webkit-scrollbar {
+ width: 0px;
+ }
+ }
+
+ .string-literal-textarea {
+ display: flex !important;
+
+ textarea {
+ min-height: calc(4 * 36px) !important;
+ }
+ }
+ }
+}
diff --git a/src/app/main/action/string-literal-input/string-literal-input.component.spec.ts b/src/app/main/action/string-literal-input/string-literal-input.component.spec.ts
new file mode 100644
index 0000000000..66ef44854c
--- /dev/null
+++ b/src/app/main/action/string-literal-input/string-literal-input.component.spec.ts
@@ -0,0 +1,346 @@
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { Component, DebugElement, OnInit, ViewChild } from '@angular/core';
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { MatButtonToggleModule } from '@angular/material/button-toggle';
+import { MatButtonHarness } from '@angular/material/button/testing';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatIconModule } from '@angular/material/icon';
+import { MatInputModule } from '@angular/material/input';
+import { MatInputHarness } from '@angular/material/input/testing';
+import { MatMenuModule } from '@angular/material/menu';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { StringLiteral } from '@dasch-swiss/dsp-js';
+import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens';
+import { SessionService } from '../../services/session.service';
+import { StringLiteralInputComponent } from './string-literal-input.component';
+
+/**
+ * test host component to simulate parent component.
+ */
+@Component({
+ template: `
+
+ `
+})
+class TestHostStringLiteralInputComponent implements OnInit {
+
+ @ViewChild('stringLiteralInputVal') stringLiteralInputComponent: StringLiteralInputComponent;
+
+ labels: StringLiteral[];
+
+ language: string;
+
+ isTextarea: boolean;
+
+ ngOnInit() {
+
+ this.labels = [
+ {
+ value: 'Welt',
+ language: 'de'
+ },
+ {
+ value: 'World',
+ language: 'en'
+ },
+ {
+ value: 'Monde',
+ language: 'fr'
+ },
+ {
+ value: 'Mondo',
+ language: 'it'
+ },
+ ];
+
+ this.language = 'en';
+ }
+}
+
+describe('StringLiteralInputComponent', () => {
+ let testHostComponent: TestHostStringLiteralInputComponent;
+ let testHostFixture: ComponentFixture;
+ let loader: HarnessLoader;
+ let sessionService: SessionService;
+
+ let sliComponentDe: DebugElement;
+ let sliMenuDebugElement: DebugElement;
+ let sliMenuNativeElement;
+ let langButton;
+
+ beforeEach(waitForAsync(() => {
+
+ // empty spy object to use in the providers for the SessionService injection
+ const dspConnSpy = { };
+
+ TestBed.configureTestingModule({
+ declarations: [
+ StringLiteralInputComponent,
+ TestHostStringLiteralInputComponent
+ ],
+ imports: [
+ MatMenuModule,
+ MatInputModule,
+ MatIconModule,
+ MatButtonToggleModule,
+ MatFormFieldModule,
+ BrowserAnimationsModule,
+ ReactiveFormsModule
+ ],
+ providers: [
+ {
+ provide: DspApiConnectionToken,
+ useValue: dspConnSpy
+ },
+ SessionService
+ ]
+ })
+ .compileComponents();
+
+ sessionService = TestBed.inject(SessionService);
+ }));
+
+ beforeEach(() => {
+ testHostFixture = TestBed.createComponent(TestHostStringLiteralInputComponent);
+ testHostComponent = testHostFixture.componentInstance;
+ loader = TestbedHarnessEnvironment.loader(testHostFixture);
+ testHostFixture.detectChanges();
+ expect(testHostComponent).toBeTruthy();
+ expect(testHostComponent.stringLiteralInputComponent).toBeTruthy();
+
+ const hostCompDe = testHostFixture.debugElement;
+ sliComponentDe = hostCompDe.query(By.directive(StringLiteralInputComponent));
+
+ expect(sliComponentDe).toBeTruthy();
+ });
+
+ it('should load values and assign them to the correct language', async () => {
+
+ const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.inputValue' }));
+
+ expect(await inputElement.getValue()).toEqual('World');
+
+ const langSelectButtonElement = await loader.getHarness(MatButtonHarness.with({ selector: '.select-lang' }));
+
+ expect(langSelectButtonElement).toBeTruthy();
+
+ // open language select button
+ await langSelectButtonElement.click();
+
+ // get reference to the mat-menu
+ sliMenuDebugElement = sliComponentDe.query(By.css('.lang-menu'));
+
+ // get reference to mat-menu native element in order to be able to access the buttons
+ sliMenuNativeElement = sliMenuDebugElement.nativeElement;
+
+ // select 'de' button
+ langButton = sliMenuNativeElement.children[0].children[0];
+
+ // simulate a user click on the button element to switch input value to the german value
+ langButton.click();
+
+ // expect the value of the german input to equal 'Welt'
+ expect(await inputElement.getValue()).toEqual('Welt');
+
+ // select 'fr' button
+ langButton = sliMenuNativeElement.children[0].children[1];
+
+ // switch to french
+ langButton.click();
+
+ // expect the value of the french input to equal 'Monde'
+ expect(await inputElement.getValue()).toEqual('Monde');
+
+ // select 'it' button
+ langButton = sliMenuNativeElement.children[0].children[2];
+
+ // switch to italian
+ langButton.click();
+
+ // expect the value of the italian input to equal 'Mondo'
+ expect(await inputElement.getValue()).toEqual('Mondo');
+ });
+
+ it('should change a value and assign it to the correct language', async () => {
+
+ const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.inputValue' }));
+
+ const langSelectButtonElement = await loader.getHarness(MatButtonHarness.with({ selector: '.select-lang' }));
+
+ expect(langSelectButtonElement).toBeTruthy();
+
+ // open language select button
+ await langSelectButtonElement.click();
+
+ // get reference to the mat-menu
+ sliMenuDebugElement = sliComponentDe.query(By.css('.lang-menu'));
+
+ // get reference to mat-menu native element in order to be able to access the buttons
+ sliMenuNativeElement = sliMenuDebugElement.nativeElement;
+
+ // select 'de' button
+ langButton = sliMenuNativeElement.children[0].children[0];
+
+ // simulate a user click on the button element to switch input value to the german value
+ langButton.click();
+
+ // expect the value of the german input to equal 'Welt'
+ expect(await inputElement.getValue()).toEqual('Welt');
+
+ // set new value for the german text
+ await inputElement.setValue('neue Welt');
+
+ // select 'fr' button
+ langButton = sliMenuNativeElement.children[0].children[1];
+
+ // switch to french
+ langButton.click();
+
+ // expect the value of the french input to equal 'Monde'
+ expect(await inputElement.getValue()).toEqual('Monde');
+
+ // select 'de' button
+ langButton = sliMenuNativeElement.children[0].children[0];
+
+ // switch back to german
+ langButton.click();
+
+ // expect the value to equal the new value given earlier
+ expect(await inputElement.getValue()).toEqual('neue Welt');
+
+ });
+
+ it('should switch input to a textarea and assign the values to the correct language', async () => {
+ testHostComponent.isTextarea = true;
+
+ testHostFixture.detectChanges();
+
+ const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.textAreaValue' }));
+
+ // get reference to the mat-menu
+ sliMenuDebugElement = sliComponentDe.query(By.css('.string-literal-select-lang'));
+
+ // get reference to mat-menu native element in order to be able to access the buttons
+ sliMenuNativeElement = sliMenuDebugElement.nativeElement;
+
+ // select 'de' button
+ langButton = sliMenuNativeElement.children[0];
+
+ // simulate a user click on the button element to switch input value to the german value
+ langButton.click();
+
+ // expect the value of the german input to equal 'Welt'
+ expect(await inputElement.getValue()).toEqual('Welt');
+
+ // select 'fr' button
+ langButton = sliMenuNativeElement.children[1];
+
+ // switch to french
+ langButton.click();
+
+ // expect the value of the french input to equal 'Monde'
+ expect(await inputElement.getValue()).toEqual('Monde');
+
+ // select 'it' button
+ langButton = sliMenuNativeElement.children[2];
+
+ // switch to italian
+ langButton.click();
+
+ // expect the value of the italian input to equal 'Mondo'
+ expect(await inputElement.getValue()).toEqual('Mondo');
+
+ // select 'en' button
+ langButton = sliMenuNativeElement.children[3];
+
+ // switch to english
+ langButton.click();
+
+ // expect the value of the english input to equal 'World'
+ expect(await inputElement.getValue()).toEqual('World');
+
+ });
+
+ it('should store a new value inside a textarea in the correct language', async () => {
+ testHostComponent.isTextarea = true;
+
+ testHostFixture.detectChanges();
+
+ const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.textAreaValue' }));
+
+ // get reference to the mat-menu
+ sliMenuDebugElement = sliComponentDe.query(By.css('.string-literal-select-lang'));
+
+ // get reference to mat-menu native element in order to be able to access the buttons
+ sliMenuNativeElement = sliMenuDebugElement.nativeElement;
+
+ // select 'en' button
+ langButton = sliMenuNativeElement.children[3];
+
+ // switch to english
+ langButton.click();
+
+ // expect the value of the english input to equal 'World'
+ expect(await inputElement.getValue()).toEqual('World');
+
+ // set new value for the german text
+ await inputElement.setValue('Brave New World');
+
+ // select 'de' button
+ langButton = sliMenuNativeElement.children[0];
+
+ // simulate a user click on the button element to switch input value to the german value
+ langButton.click();
+
+ // expect the value of the german input to equal 'Welt'
+ expect(await inputElement.getValue()).toEqual('Welt');
+
+ // select 'en' button again
+ langButton = sliMenuNativeElement.children[3];
+
+ // switch back to english
+ langButton.click();
+
+ // expect the value of the english input to equal the new value 'Brave New World'
+ expect(await inputElement.getValue()).toEqual('Brave New World');
+
+ });
+
+ it('should update values and assign them to the correct language when langauges object is changed', async () => {
+
+ const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.inputValue' }));
+
+ expect(await inputElement.getValue()).toEqual('World');
+
+ testHostComponent.labels = [
+ {
+ value: 'Welt',
+ language: 'de'
+ },
+ {
+ value: 'Brave New World',
+ language: 'en'
+ },
+ {
+ value: 'Monde',
+ language: 'fr'
+ },
+ {
+ value: 'Mondo',
+ language: 'it'
+ },
+ ];
+
+ testHostFixture.detectChanges();
+
+ expect(await inputElement.getValue()).toEqual('Brave New World');
+ });
+});
diff --git a/src/app/main/action/string-literal-input/string-literal-input.component.ts b/src/app/main/action/string-literal-input/string-literal-input.component.ts
new file mode 100644
index 0000000000..a67b8d65f7
--- /dev/null
+++ b/src/app/main/action/string-literal-input/string-literal-input.component.ts
@@ -0,0 +1,296 @@
+import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core';
+import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
+import { MatMenuTrigger } from '@angular/material/menu';
+import { StringLiteral } from '@dasch-swiss/dsp-js';
+import { SessionService } from '../../services/session.service';
+
+@Component({
+ selector: 'app-string-literal-input',
+ templateUrl: './string-literal-input.component.html',
+ styleUrls: ['./string-literal-input.component.scss']
+})
+export class StringLiteralInputComponent implements OnInit, OnChanges {
+
+ /**
+ * optional placeholder for the input field e.g. Label
+ *
+ * @param {string} [placeholder='Label']
+ */
+ @Input() placeholder = 'Label';
+
+ /**
+ * optional predefined (selected) language: en, de, it, fr, etc.
+ *
+ * @param {string} language
+ */
+ @Input() language: string;
+
+ /**
+ * optional form field input type: textarea? set to true for textarea
+ * otherwise it's a simple (short) input field
+ *
+ * @param {boolean} [textarea=false]
+ */
+ @Input() textarea: boolean;
+
+ /**
+ * optional form field value of type StringLiteral[]
+ *
+ * @param {StringLiteral[]} value
+ */
+ @Input() value: StringLiteral[] = [];
+
+ /**
+ * optional disable the input field in case of no right to edit the field/value
+ *
+ * @param {boolean}: [disabled=false]
+ */
+ @Input() disabled: boolean;
+
+ /**
+ * the readonly attribute specifies whether the control may be modified by the user.
+ *
+ * @param {boolean}: [readonly=false]
+ */
+ @Input() readonly: boolean;
+
+ /**
+ * returns (output) an array of StringLiteral on any change on the input field.
+ *
+ * @emits {StringLiteral[]} dataChanged
+ */
+ @Output() dataChanged: EventEmitter = new EventEmitter();
+
+ /**
+ * returns (output) true when the field was touched. This can be used to validate data, e.g. in case a value is required
+ *
+ * @emits {boolean} touched
+ */
+ @Output() touched: EventEmitter = new EventEmitter();
+
+ /**
+ * returns true when a user press ENTER. This can be used to submit data in the parent component.
+ *
+ * * @emits {boolean} enter
+ */
+ @Output() enter: EventEmitter = new EventEmitter();
+
+ @ViewChild('textInput', { static: false }) textInput: ElementRef;
+
+ @ViewChild('btnToSelectLanguage', { static: false }) btnToSelectLanguage: MatMenuTrigger;
+
+ form: FormGroup;
+ languages: string[] = ['de', 'fr', 'it', 'en'];
+
+ constructor(private _fb: FormBuilder,
+ private _sessionService: SessionService) {
+
+ // set selected language, if it's not defined yet
+ if (!this.language) {
+ if (this._sessionService.getSession() !== null) {
+ // get language from the logged-in user profile data
+ this.language = this._sessionService.getSession().user.lang;
+ } else {
+ // get default language from browser
+ this.language = navigator.language.substr(0, 2);
+ }
+ }
+
+ // does the defined language exists in our supported languages list?
+ if (this.languages.indexOf(this.language) === -1) {
+ // if not, select the first language from our list of supported languages
+ this.language = this.languages[0];
+ }
+
+ }
+
+ ngOnInit() {
+
+ // reset stringLiterals if they have empty values
+ this.resetValues();
+
+ // build the form
+ this.form = this._fb.group({
+ text: new FormControl(
+ {
+ value: '',
+ disabled: this.disabled // https://stackoverflow.com/a/47521965
+ }
+ )
+ });
+ // update values on form change
+ this.form.valueChanges.subscribe(data => this.onValueChanged());
+
+ // get value from stringLiterals
+ const val = this.getValueFromStringLiteral(this.language);
+ this.updateFormField(val);
+ }
+
+ ngOnChanges() {
+ // get value from stringLiterals
+ const val = this.getValueFromStringLiteral(this.language);
+ this.updateFormField(val);
+ }
+
+ /**
+ * @ignore
+ *
+ * emit data to parent on any change on the input field
+ */
+ onValueChanged() {
+ if (!this.form) {
+ return;
+ }
+
+ const form = this.form;
+ const control = form.get('text');
+ this.touched.emit(control && control.dirty);
+
+ this.updateStringLiterals(this.language, this.form.controls.text.value);
+
+ this.dataChanged.emit(this.value);
+
+ }
+
+ toggleAll() {
+ // tODO: open/show all languages with their values
+ }
+
+ /**
+ * @ignore
+ *
+ * Set the language after selecting;
+ * This updates the array of StringLiterals: adds item with the selected language if it doesn't exist
+ */
+ setLanguage(lang: string) {
+
+ if (this.language !== lang) {
+ // clean stringLIteral value for previous language, if text field is empty
+ this.updateStringLiterals(this.language, this.form.controls.text.value);
+
+ this.language = lang;
+ // update form field value / reset in case of no value
+ const val = this.getValueFromStringLiteral(lang);
+ this.updateFormField(val);
+ }
+ }
+
+ /**
+ * @ignore
+ *
+ * Switch focus to input field after selecting a language
+ */
+ switchFocus() {
+ // close the menu
+ if (!this.textarea && this.btnToSelectLanguage && this.btnToSelectLanguage.menuOpen) {
+ this.btnToSelectLanguage.closeMenu();
+ }
+
+ if (!this.disabled) {
+ this.form.controls.text.enable();
+ this.textInput.nativeElement.focus();
+ }
+ }
+
+ /**
+ * @ignore
+ *
+ * Switch focus to input field after closing the menu by clicking anywhere outside of it
+ */
+ menuClosed() {
+ if (!this.disabled) {
+ this.form.controls.text.enable();
+ this.textInput.nativeElement.focus();
+ }
+ }
+
+ /**
+ * @ignore
+ *
+ * Set the value in the input field
+ */
+ updateFormField(value: string) {
+ if (!value) {
+ value = '';
+ }
+ if (!this.form) {
+ return;
+ }
+ this.form.controls.text.setValue(value);
+ }
+
+ /**
+ * @ignore
+ *
+ * Update the array of StringLiterals depending on value / empty value add or remove item from array.
+ */
+ updateStringLiterals(lang: string, value?: string) {
+ const index = this.value.findIndex(i => i.language === lang);
+
+ if (index > -1 && this.value[index].value.length > 0) {
+ // value is not empty and exists in list of stringLiterals
+ this.value[index].value = value;
+ }
+
+ if ((!value || value.length === 0) && index > -1) {
+ // value is empty: delete stringLiteral item for this language
+ this.value.splice(index, 1);
+ }
+
+ if (index < 0 && value) {
+ // value doesn't exist in stringLiterals: add one
+ const newValue: StringLiteral = {
+ value: value,
+ language: lang
+ };
+ this.value.push(newValue);
+ }
+
+ }
+
+ /**
+ * @ignore
+ *
+ * In case of strange array of StringLiterals, this method will reset to a API-conform array. This means an array without empty values.
+ */
+ resetValues() {
+ const length: number = this.value.length;
+
+ if (length > 0) {
+ let index = length - 1;
+ while (index >= 0) {
+ // remove items with empty value
+ if (!this.value[index].value.length) {
+ this.value.splice(index, 1);
+ }
+ index--;
+ }
+
+ // does an item for selected lanuage exists
+ if (this.value.findIndex(i => i.language === this.language) === -1) {
+ this.language = this.value[0].language;
+ }
+
+ } else {
+ this.value = [];
+ }
+ }
+
+ /**
+ * @ignore
+ *
+ * Get the value from array of StringLiterals for the selected language
+ */
+ getValueFromStringLiteral(lang: string): string {
+ // get index for this language
+ const index = this.value.findIndex(i => i.language === lang);
+
+ if (this.value[index] && this.value[index].value.length > 0) {
+ return this.value[index].value;
+ } else {
+ return undefined;
+ }
+
+ }
+
+}
diff --git a/src/app/main/directive/admin-image/admin-image.config.ts b/src/app/main/directive/admin-image/admin-image.config.ts
new file mode 100644
index 0000000000..d0bb349f0e
--- /dev/null
+++ b/src/app/main/directive/admin-image/admin-image.config.ts
@@ -0,0 +1,21 @@
+/* eslint-disable max-len */
+/**
+ * @ignore
+ */
+export class AdminImageConfig {
+
+ /**
+ * default-project-logo
+ */
+ public static defaultProject = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAAuUlEQVR4Ae2XP8rCUBAHp5F4gPxBsA45mpUgXkt4Se4Rkc97fIQkhVZrK+JbxGwhujN9Bh77K8IPsWTPkSsXOnYkGLPmjNx5YoUhCX/Igx0LzNgiT9zwBhU1AxLxQEpGQCJOtFT653tEMQUgRxR7LVEjqhkABaLaEGVAVAM5BQ2iOhJFjPSAXeBVPKADfqa+Aw/4Dr53Bx6wD/iZfkZgQgwcidIiBgb0H5CZ/lOClmgYZzxOoMRxjLkBL3E6cltSSnYAAAAASUVORK5CYII=';
+
+ /**
+ * default-user-avatar icon
+ */
+ public static defaultUser = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAA+klEQVR4Ae3SMUrDYBjG8X+n1kPoKg4l5g6Cu7jokaxbW5KhNxAcdZMiiOgB2iaXMChKO5jHrEr7Ncn7OSjf77/nScJLEAQNxKTkrKoyEiK82mGCvlWS0vP3+Hu0pqmviQnaUIIHMdpYSYRZihyNMcuRozlmK+Ro+QcGMuRohlmCHA0xiygdZ9qH3zzUEV70mKI13dEFXxMp5Y+fM6KLVxFj5iyrZgzpE/wre5xzyS0LCj6rChbcMOCMXYxiBuTIUcYFh7TQ4ZRnVLMnTujQwAGPqGEP7FPTMW+oRa8c1Xv7D9Sy9zpfcY0MXbFVgQy9sJWMNR8IA0EQfAFx/QsJxgdnsQAAAABJRU5ErkJggg==';
+
+ /**
+ * default "not found" image in case of 404 error
+ */
+ public static defaultNotFound = 'data:image/png;base64,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';
+}
diff --git a/src/app/main/directive/admin-image/admin-image.directive.spec.ts b/src/app/main/directive/admin-image/admin-image.directive.spec.ts
new file mode 100644
index 0000000000..9cbcf8c5a5
--- /dev/null
+++ b/src/app/main/directive/admin-image/admin-image.directive.spec.ts
@@ -0,0 +1,71 @@
+/* eslint-disable max-len */
+import { AdminImageDirective } from './admin-image.directive';
+import { Component, DebugElement } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+@Component({
+ template: `
+ `
+})
+class TestAdminImageComponent {
+
+ img = 'http://dasch.swiss/content/images/2017/11/DaSCH_Logo_RGB.png';
+ type = 'project';
+
+ constructor() { }
+}
+
+describe('Directive: AdminImageDirective', () => {
+ let component: TestAdminImageComponent;
+ let fixture: ComponentFixture;
+ let imageEl: DebugElement;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ AdminImageDirective,
+ TestAdminImageComponent
+ ]
+ });
+
+ fixture = TestBed.createComponent(TestAdminImageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ imageEl = fixture.debugElement.query(By.css('img'));
+
+ });
+
+ it('should create an instance', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should display the project logo of the DaSCH', () => {
+ expect(imageEl.nativeElement.src).toBe('http://dasch.swiss/content/images/2017/11/DaSCH_Logo_RGB.png');
+ });
+
+ it('should display the default project logo if the image is null or undefined', () => {
+ component.img = null;
+ fixture.detectChanges();
+
+ expect(imageEl.nativeElement.src).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAAuUlEQVR4Ae2XP8rCUBAHp5F4gPxBsA45mpUgXkt4Se4Rkc97fIQkhVZrK+JbxGwhujN9Bh77K8IPsWTPkSsXOnYkGLPmjNx5YoUhCX/Igx0LzNgiT9zwBhU1AxLxQEpGQCJOtFT653tEMQUgRxR7LVEjqhkABaLaEGVAVAM5BQ2iOhJFjPSAXeBVPKADfqa+Aw/4Dr53Bx6wD/iZfkZgQgwcidIiBgb0H5CZ/lOClmgYZzxOoMRxjLkBL3E6cltSSnYAAAAASUVORK5CYII=');
+ });
+
+ it('should detect the change of type to "user" and display the user logo', () => {
+ component.img = 'salsah@milchkannen.ch';
+ component.type = 'user';
+ fixture.detectChanges();
+
+ expect(imageEl.nativeElement.src).toBe('http://www.gravatar.com/avatar/dd74bbb106986f9ef074743e3c7fc555?d=mp&s=256');
+ });
+
+ it('should display the default user logo if the image is null or undefined', () => {
+ component.img = undefined;
+ component.type = 'user';
+ fixture.detectChanges();
+
+ expect(imageEl.nativeElement.src).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAA+klEQVR4Ae3SMUrDYBjG8X+n1kPoKg4l5g6Cu7jokaxbW5KhNxAcdZMiiOgB2iaXMChKO5jHrEr7Ncn7OSjf77/nScJLEAQNxKTkrKoyEiK82mGCvlWS0vP3+Hu0pqmviQnaUIIHMdpYSYRZihyNMcuRozlmK+Ro+QcGMuRohlmCHA0xiygdZ9qH3zzUEV70mKI13dEFXxMp5Y+fM6KLVxFj5iyrZgzpE/wre5xzyS0LCj6rChbcMOCMXYxiBuTIUcYFh7TQ4ZRnVLMnTujQwAGPqGEP7FPTMW+oRa8c1Xv7D9Sy9zpfcY0MXbFVgQy9sJWMNR8IA0EQfAFx/QsJxgdnsQAAAABJRU5ErkJggg==');
+ });
+
+});
diff --git a/src/app/main/directive/admin-image/admin-image.directive.ts b/src/app/main/directive/admin-image/admin-image.directive.ts
new file mode 100644
index 0000000000..41cbeb7179
--- /dev/null
+++ b/src/app/main/directive/admin-image/admin-image.directive.ts
@@ -0,0 +1,87 @@
+import { Directive, OnChanges, Input, Renderer2, ElementRef } from '@angular/core';
+import { AdminImageConfig } from './admin-image.config';
+import { Md5 } from 'ts-md5';
+
+@Directive({
+ selector: '[appAdminImage]'
+})
+export class AdminImageDirective implements OnChanges {
+
+ /**
+ * @param {string} image
+ *
+ * source of the image;
+ * - in the case of user (gr)avatar it's the e-mail address,
+ * - in the case of project logo it's the image url
+ */
+ @Input() image: string;
+
+ /**
+ * @param {string} type
+ *
+ * type of image; you can use it with
+ * - project
+ * - user
+ */
+ @Input() type: string;
+
+
+ /**
+ * @ignore
+ */
+ source: string;
+
+
+ /**
+ * @ignore
+ */
+ onError: string = AdminImageConfig.defaultNotFound;
+
+
+ /**
+ * @ignore
+ */
+ constructor(private _renderer: Renderer2,
+ private _ele: ElementRef
+ ) { }
+
+ /**
+ * @ignore
+ */
+ ngOnChanges() {
+
+ this.source = this.image;
+
+ switch (this.type) {
+
+ case 'user':
+ this.onError = AdminImageConfig.defaultUser;
+
+ if (this.image === null || this.image === undefined) {
+ this.source = AdminImageConfig.defaultUser;
+ } else {
+ this.source = location.protocol + '//www.gravatar.com/avatar/' + Md5.hashStr(this.image) + '?d=mp&s=256';
+ }
+
+ break;
+
+ case 'project':
+ this.onError = AdminImageConfig.defaultProject;
+
+ if (this.image === null || this.image === undefined) {
+
+ this.source = AdminImageConfig.defaultProject;
+ }
+
+ break;
+
+ default:
+ this.source = this.image;
+ }
+
+ this._renderer.setAttribute(this._ele.nativeElement, 'src', this.source);
+ this._renderer.setAttribute(this._ele.nativeElement, 'onError', 'this.src=\'' + this.onError + '\'');
+
+ }
+
+}
diff --git a/src/app/main/directive/existing-name/existing-name.directive.spec.ts b/src/app/main/directive/existing-name/existing-name.directive.spec.ts
new file mode 100644
index 0000000000..5dc660caf5
--- /dev/null
+++ b/src/app/main/directive/existing-name/existing-name.directive.spec.ts
@@ -0,0 +1,216 @@
+
+import { Component, OnInit } from '@angular/core';
+import { FormGroup, FormBuilder, FormControl, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { ExistingNameDirective, existingNamesValidator } from './existing-name.directive';
+
+@Component({
+ template: `
+
+
+
+
+
+ `
+})
+class TestHostComponent implements OnInit {
+
+ dataMock: string[] = [
+ 'Ben', 'Tobias', 'André', 'Flavie', 'Ivan', 'Lucas', 'Mike'
+ ];
+
+ existingNames: [RegExp] = [
+ new RegExp('user')
+ ];
+
+ form: FormGroup;
+
+ formErrors = {
+ name: ''
+ };
+
+ validationMessages = {
+ name: {
+ required: 'A name is required',
+ existingName: 'This name exists already.'
+ }
+ };
+
+ constructor(private _formBuilder: FormBuilder) { }
+
+ ngOnInit() {
+ // create a list of existing names
+ let i = 1;
+ for (const user of this.dataMock) {
+ this.existingNames[i] = new RegExp('(?:^|W)' + user.toLowerCase() + '(?:$|W)');
+
+ i++;
+ }
+
+ // build form
+ this.form = this._formBuilder.group({
+ name: new FormControl({
+ value: '', disabled: false
+ }, [
+ Validators.required,
+ existingNamesValidator(this.existingNames)
+ ])
+ });
+
+ // detect changes in the form
+ this.form.valueChanges.subscribe(
+ data => this.onValueChanged(data)
+ );
+
+ this.onValueChanged();
+ }
+
+ onValueChanged(data?: any) {
+
+ if (!this.form) {
+ return;
+ }
+
+ // check if the form is valid
+ Object.keys(this.formErrors).map(field => {
+ this.formErrors[field] = '';
+ const control = this.form.get(field);
+ if (control && control.dirty && !control.valid) {
+ const messages = this.validationMessages[field];
+ Object.keys(control.errors).map(key => {
+ this.formErrors[field] += messages[key] + ' ';
+ });
+ }
+ });
+ }
+}
+
+describe('ExistingNameDirective', () => {
+
+ let component: TestHostComponent;
+ let fixture: ComponentFixture;
+ const existingNamesList: string[] = [
+ 'Ben', 'Tobias', 'André', 'Flavie', 'Ivan', 'Lucas', 'Mike'
+ ];
+ const existingNames: [RegExp] = [
+ new RegExp('user')
+ ];
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ ReactiveFormsModule, FormsModule,
+ MatFormFieldModule, MatInputModule, BrowserAnimationsModule
+ ],
+ declarations: [
+ ExistingNameDirective,
+ TestHostComponent
+ ]
+ });
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ });
+
+ it('should create an instance', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('form invalid when empty', () => {
+ expect(component.form.valid).toBeFalsy();
+ });
+
+ it('name input field validity', () => {
+ const name = component.form.controls.name;
+ expect(name.valid).toBeFalsy();
+ });
+
+ it('should recognize the new name "Benjamin" and validate the form', () => {
+ expect(component.dataMock).toEqual(existingNamesList);
+ expect(component.form.valid).toBeFalsy();
+
+ let i = 1;
+ for (const user of existingNamesList) {
+ existingNames[i] = new RegExp('(?:^|W)' + user.toLowerCase() + '(?:$|W)');
+
+ i++;
+ }
+
+ expect(component.existingNames).toEqual(existingNames);
+
+ fixture.detectChanges();
+
+ const name = component.form.controls.name;
+
+ let errors = {};
+ errors = name.errors || {};
+ expect(name.valid).toBeFalsy();
+
+ // name field is required
+ expect(errors['required']).toBeTruthy();
+ expect(existingNamesValidator(existingNames));
+
+ // set a new name
+ name.setValue('Benjamin');
+ fixture.detectChanges();
+
+ errors = name.errors || {};
+
+ expect(component.form.valid).toBeTruthy();
+ expect(errors['required']).toBeFalsy();
+ expect(existingNamesValidator(existingNames)).toBeTruthy();
+ expect(component.form.controls.name.errors).toEqual(null);
+ });
+
+ it('should recognize the existing name "Ben" and invalid the form', () => {
+ fixture.detectChanges();
+ expect(component.dataMock).toEqual(existingNamesList);
+ expect(component.form.valid).toBeFalsy();
+
+ let i = 1;
+ for (const user of existingNamesList) {
+ existingNames[i] = new RegExp('(?:^|W)' + user.toLowerCase() + '(?:$|W)');
+
+ i++;
+ }
+
+ expect(component.existingNames).toEqual(existingNames);
+
+ let errors = {};
+ const name = component.form.controls.name;
+ expect(name.valid).toBeFalsy();
+
+ // name field is required
+ errors = name.errors || {};
+ expect(errors['required']).toBeTruthy();
+ expect(existingNamesValidator(existingNames));
+
+ // set an existing name
+ name.setValue('Ben');
+ fixture.detectChanges();
+
+ errors = name.errors || {};
+ expect(component.form.valid).toBeFalsy();
+ expect(errors['required']).toBeFalsy();
+ expect(existingNamesValidator(existingNames)).toBeTruthy();
+ expect(component.form.controls.name.errors.existingName.name).toEqual('ben');
+ });
+
+});
diff --git a/src/app/main/directive/existing-name/existing-name.directive.ts b/src/app/main/directive/existing-name/existing-name.directive.ts
new file mode 100644
index 0000000000..20eef3cc4b
--- /dev/null
+++ b/src/app/main/directive/existing-name/existing-name.directive.ts
@@ -0,0 +1,112 @@
+/* eslint-disable @typescript-eslint/no-use-before-define */
+import { Directive, OnChanges, Input, SimpleChanges } from '@angular/core';
+import { Validators, AbstractControl, ValidatorFn } from '@angular/forms';
+
+@Directive({
+ selector: '[appExistingNames]'
+})
+export class ExistingNameDirective implements Validators, OnChanges {
+
+ /**
+ * @ignore
+ */
+ @Input() existingName: string;
+
+ /**
+ * @ignore
+ */
+ private _valFn = Validators.nullValidator;
+
+ /**
+ * @ignore
+ * @param changes
+ */
+ ngOnChanges(changes: SimpleChanges): void {
+ const change = changes['existingName'];
+ if (change) {
+ const val: string | RegExp = change.currentValue;
+ const re = val instanceof RegExp ? val : new RegExp(val);
+ this._valFn = existingNameValidator(re);
+ } else {
+ this._valFn = Validators.nullValidator;
+ }
+ }
+
+ /**
+ * @ignore
+ * @param control
+ */
+ validate(control: AbstractControl): { [key: string]: any } {
+ return this._valFn(control);
+ }
+}
+
+/**
+ * validation of existing name value. String method (only one value);
+ * Use it in a "formbuilder" group as a validator property
+ *
+ * @param {RegExp} valRegexp Only one regular expression value
+ * @returns ValidatorFn
+ */
+export function existingNameValidator(valRegexp: RegExp): ValidatorFn {
+ return (control: AbstractControl): { [key: string]: any } => {
+ let name;
+
+ if (control.value) {
+ name = control.value.toLowerCase();
+ }
+
+ const no = valRegexp.test(name);
+ return no ? { 'existingName': { name } } : null;
+ };
+}
+
+/**
+ * validation of existing name values. Array method (list of values)
+ * Use it in a "formbuilder" group as a validator property
+ *
+ * @param {RegExp} valArrayRegexp List of regular expression values
+ * @returns ValidatorFn
+ */
+export function existingNamesValidator(valArrayRegexp: [RegExp]): ValidatorFn {
+
+ return (control: AbstractControl): { [key: string]: any } => {
+
+ let name;
+
+ if (control.value) {
+ name = control.value.toLowerCase();
+ }
+
+ let no;
+ for (const existing of valArrayRegexp) {
+ no = existing.test(name);
+ if (no) {
+ // console.log(no);
+ return no ? { 'existingName': { name } } : null;
+ }
+ }
+ return no ? { 'existingName': { name } } : null;
+ };
+}
+
+/**
+ * @ignore
+ *
+ * @param {RegExp} pattern
+ * @param {string} regType
+ * @returns ValidatorFn
+ */
+export function notAllowed(pattern: RegExp, regType: string): ValidatorFn {
+ return (control: AbstractControl): { [key: string]: any } => {
+ let name;
+
+ if (control.value) {
+ name = control.value.toLowerCase();
+ }
+
+ const no = pattern.test(name);
+ return no ? { regType: { name } } : null;
+ };
+}
+
diff --git a/src/app/main/directive/gnd/gnd.directive.spec.ts b/src/app/main/directive/gnd/gnd.directive.spec.ts
new file mode 100644
index 0000000000..f04e2e849e
--- /dev/null
+++ b/src/app/main/directive/gnd/gnd.directive.spec.ts
@@ -0,0 +1,145 @@
+import { Component, DebugElement, ElementRef, OnInit } from '@angular/core';
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { GndDirective } from './gnd.directive';
+
+/**
+ * test component for a GND/IAF identifier.
+ */
+@Component({
+ template: ''
+})
+class TestGnd1Component { }
+
+/**
+ * test component for a VIAF identifier.
+ */
+@Component({
+ template: ''
+})
+class TestGnd2Component { }
+
+/**
+ * test component for normal text.
+ */
+@Component({
+ template: ''
+})
+class TestGnd3Component { }
+
+/**
+ * test component for long normal text.
+ */
+@Component({
+ template: ``
+})
+class TestGnd4Component { }
+
+/**
+ * test component with an updated text.
+ */
+@Component({
+ template: ''
+})
+class TestGnd5Component implements OnInit {
+
+ gndValue;
+
+ ngOnInit() {
+ this.gndValue = 'initial text';
+ }
+}
+
+class MockElementRef implements ElementRef {
+ nativeElement = {};
+}
+
+
+describe('GndDirective', () => {
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [TestGnd1Component, TestGnd2Component, TestGnd3Component, TestGnd4Component, TestGnd5Component, GndDirective],
+ providers: [
+ { provide: ElementRef, useClass: MockElementRef }
+ ]
+ }).compileComponents();
+
+ }));
+
+ it('should create an instance', () => {
+
+ const directive = new GndDirective(new MockElementRef());
+ expect(directive).toBeTruthy();
+
+ });
+
+ it('should transform a GND/IAF identifier to a hyperlink pointing to the resolver', () => {
+
+ const fixture: ComponentFixture = TestBed.createComponent(TestGnd1Component);
+ fixture.detectChanges();
+
+ const spanEle: DebugElement = fixture.debugElement.query(By.css('span'));
+
+ expect(spanEle.nativeElement.innerHTML).toEqual('(DE-588)118696149');
+
+ });
+
+ it('should transform a VIAF identifier to a hyperlink pointing to the resolver', () => {
+
+ const fixture: ComponentFixture = TestBed.createComponent(TestGnd2Component);
+ fixture.detectChanges();
+
+ const spanEle: DebugElement = fixture.debugElement.query(By.css('span'));
+
+ expect(spanEle.nativeElement.innerHTML).toEqual('(VIAF)22936072');
+
+ });
+
+ it('should not transform normal text', () => {
+
+ const fixture: ComponentFixture = TestBed.createComponent(TestGnd3Component);
+ fixture.detectChanges();
+
+ const spanEle: DebugElement = fixture.debugElement.query(By.css('span'));
+
+ expect(spanEle.nativeElement.innerHTML).toEqual('normal text');
+
+ });
+
+ it('should not transform long normal text', () => {
+
+ const fixture: ComponentFixture = TestBed.createComponent(TestGnd4Component);
+ fixture.detectChanges();
+
+ const spanEle: DebugElement = fixture.debugElement.query(By.css('span'));
+
+ expect(spanEle.nativeElement.innerHTML).toEqual('normal text that is quite long and will not even be looked at because it cannot possibly be a GND/IAF or VIAF identifier');
+
+ });
+
+ it('should be equal to the updated text', () => {
+
+ const fixture: ComponentFixture = TestBed.createComponent(TestGnd5Component);
+ fixture.detectChanges();
+
+ const spanEle: DebugElement = fixture.debugElement.query(By.css('span'));
+
+ expect(spanEle.nativeElement.innerHTML).toEqual('initial text');
+
+
+ fixture.componentInstance.gndValue = 'updated text';
+ fixture.detectChanges();
+
+ expect(spanEle.nativeElement.innerHTML).toEqual('updated text');
+
+ });
+
+
+});
+
+
+
+
+
diff --git a/src/app/main/directive/gnd/gnd.directive.ts b/src/app/main/directive/gnd/gnd.directive.ts
new file mode 100644
index 0000000000..7f4194f941
--- /dev/null
+++ b/src/app/main/directive/gnd/gnd.directive.ts
@@ -0,0 +1,57 @@
+import { Directive, ElementRef, Input, OnChanges } from '@angular/core';
+
+export class GNDConstants {
+ public static GND_PREFIX = '(DE-588)';
+ public static GND_RESOLVER = 'http://d-nb.info/gnd/';
+
+ public static VIAF_PREFIX = '(VIAF)';
+ public static VIAF_RESOLVER = 'https://viaf.org/viaf/';
+}
+
+/**
+ * this directive renders a GND/IAF or a VIAF identifier as a link to the respective resolver.
+ */
+@Directive({
+ selector: '[appGnd]'
+})
+export class GndDirective implements OnChanges {
+
+ @Input()
+ set gnd(value: string) {
+ this._gnd = value;
+ }
+
+ get gnd() {
+ return this._gnd;
+ }
+
+
+ // the GND identifier to be rendered
+ private _gnd: string;
+
+ constructor(private _ele: ElementRef) {
+
+ }
+
+ ngOnChanges() {
+ if (this._gnd.length < 30) {
+
+ if (this._gnd.indexOf(GNDConstants.GND_PREFIX) === 0) {
+ // gnd/iaf identifier
+ this._ele.nativeElement.innerHTML = `${this._gnd}`;
+ } else if (this._gnd.indexOf(GNDConstants.VIAF_PREFIX) === 0) {
+ // viaf identifier
+ this._ele.nativeElement.innerHTML = `${this._gnd}`;
+ } else {
+ // no identifier, leave unchanged
+ this._ele.nativeElement.innerHTML = this._gnd;
+ }
+
+ } else {
+ // no identifier, leave unchanged
+ this._ele.nativeElement.innerHTML = this._gnd;
+ }
+
+ }
+
+}
diff --git a/src/app/main/pipes/formatting/formatted-boolean.pipe.spec.ts b/src/app/main/pipes/formatting/formatted-boolean.pipe.spec.ts
new file mode 100644
index 0000000000..862c0bb6e7
--- /dev/null
+++ b/src/app/main/pipes/formatting/formatted-boolean.pipe.spec.ts
@@ -0,0 +1,45 @@
+import { FormattedBooleanPipe } from './formatted-boolean.pipe';
+
+describe('FormattedBooleanPipe', () => {
+ let pipe: FormattedBooleanPipe;
+
+ beforeEach(() => {
+ pipe = new FormattedBooleanPipe();
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should return "true" (no format specified)', () => {
+ const myBoolean = true;
+
+ const convertedBoolean = pipe.transform(myBoolean);
+
+ expect(convertedBoolean).toEqual('true');
+ });
+
+ it('should return "false" (format specified)', () => {
+ const myBoolean = false;
+
+ const convertedBoolean = pipe.transform(myBoolean, 'true-false');
+
+ expect(convertedBoolean).toEqual('false');
+ });
+
+ it('should return "yes"', () => {
+ const myBoolean = true;
+
+ const convertedBoolean = pipe.transform(myBoolean, 'yes-no');
+
+ expect(convertedBoolean).toEqual('yes');
+ });
+
+ it('should return "off"', () => {
+ const myBoolean = false;
+
+ const convertedBoolean = pipe.transform(myBoolean, 'on-off');
+
+ expect(convertedBoolean).toEqual('off');
+ });
+});
diff --git a/src/app/main/pipes/formatting/formatted-boolean.pipe.ts b/src/app/main/pipes/formatting/formatted-boolean.pipe.ts
new file mode 100644
index 0000000000..0c48c9b1e5
--- /dev/null
+++ b/src/app/main/pipes/formatting/formatted-boolean.pipe.ts
@@ -0,0 +1,32 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+interface DisplayTypes {
+ value: string;
+ viewValue: string;
+}
+
+@Pipe({
+ name: 'formattedBoolean'
+})
+export class FormattedBooleanPipe implements PipeTransform {
+
+ displayTypes: DisplayTypes[] = [
+ { value: 'true-false', viewValue: 'True/False' },
+ { value: 'yes-no', viewValue: 'Yes/No' },
+ { value: 'on-off', viewValue: 'On/Off' }
+ ];
+
+ transform(value: boolean, format?: string): string {
+ switch (format) {
+ case 'true-false':
+ return value ? 'true' : 'false';
+ case 'yes-no':
+ return value ? 'yes' : 'no';
+ case 'on-off':
+ return value ? 'on' : 'off';
+ default:
+ return value ? 'true' : 'false';
+ }
+ }
+
+}
diff --git a/src/app/main/pipes/formatting/knoradate.pipe.spec.ts b/src/app/main/pipes/formatting/knoradate.pipe.spec.ts
new file mode 100644
index 0000000000..4fafa0eb7e
--- /dev/null
+++ b/src/app/main/pipes/formatting/knoradate.pipe.spec.ts
@@ -0,0 +1,98 @@
+import { KnoraDate } from '@dasch-swiss/dsp-js';
+import { KnoraDatePipe } from './knoradate.pipe';
+
+describe('KnoradatePipe', () => {
+ let pipe: KnoraDatePipe;
+
+ beforeEach(() => {
+ pipe = new KnoraDatePipe();
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should return a date string', () => {
+ const date = new KnoraDate('GREGORIAN', 'AD', 1993, 10, 10);
+
+ const convertedDate = pipe.transform(date);
+
+ expect(convertedDate).toEqual('10.10.1993');
+ });
+
+ it('should return the correct format for a date with day precision depending on the format provided', () => {
+ const date = new KnoraDate('GREGORIAN', 'AD', 1776, 7, 4);
+
+ let convertedDate = pipe.transform(date, 'dd.MM.YYYY');
+
+ expect(convertedDate).toEqual('04.07.1776');
+
+ convertedDate = pipe.transform(date, 'dd-MM-YYYY');
+
+ expect(convertedDate).toEqual('04-07-1776');
+
+ convertedDate = pipe.transform(date, 'MM/dd/YYYY');
+
+ expect(convertedDate).toEqual('07/04/1776');
+
+ // should default to dd.MM.YYYY in the event of an invalid format
+ convertedDate = pipe.transform(date, 'invalid format');
+
+ expect(convertedDate).toEqual('04.07.1776');
+ });
+
+ it ('should return a string with the desired display options', () => {
+ const date = new KnoraDate('GREGORIAN', 'AD', 1776, 7, 4);
+
+ let dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'era');
+
+ expect(dateWithDisplayOptions).toEqual('04.07.1776 AD');
+
+ dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'calendar');
+
+ expect(dateWithDisplayOptions).toEqual('04.07.1776 GREGORIAN');
+
+ dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'all');
+
+ expect(dateWithDisplayOptions).toEqual('04.07.1776 AD GREGORIAN');
+ });
+
+ it ('should return a string with the desired display options for a date without era', () => {
+ const date = new KnoraDate('ISLAMIC', 'noEra', 1441, 7, 4);
+
+ let dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'era');
+
+ expect(dateWithDisplayOptions).toEqual('04.07.1441');
+
+ dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'calendar');
+
+ expect(dateWithDisplayOptions).toEqual('04.07.1441 ISLAMIC');
+
+ dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'all');
+
+ expect(dateWithDisplayOptions).toEqual('04.07.1441 ISLAMIC');
+ });
+
+ it ('should return a string with only the month and the year', () => {
+ const date = new KnoraDate('GREGORIAN', 'AD', 1776, 7);
+
+ const convertedDate = pipe.transform(date, 'dd.MM.YYYY');
+
+ expect(convertedDate).toEqual('07.1776');
+ });
+
+ it ('should return a string with only the year', () => {
+ const date = new KnoraDate('GREGORIAN', 'AD', 1776);
+
+ const convertedDate = pipe.transform(date, 'dd.MM.YYYY');
+
+ expect(convertedDate).toEqual('1776');
+ });
+
+ it('should return a number of two digits', () => {
+ let num = pipe.leftPadding(7);
+
+ expect(num).toEqual('07');
+
+ num = pipe.leftPadding(12);
+
+ expect(num).toEqual('12');
+ });
+});
diff --git a/src/app/main/pipes/formatting/knoradate.pipe.ts b/src/app/main/pipes/formatting/knoradate.pipe.ts
new file mode 100644
index 0000000000..ac751d81f8
--- /dev/null
+++ b/src/app/main/pipes/formatting/knoradate.pipe.ts
@@ -0,0 +1,82 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { KnoraDate } from '@dasch-swiss/dsp-js';
+
+@Pipe({
+ name: 'knoraDate'
+})
+export class KnoraDatePipe implements PipeTransform {
+
+ transform(date: KnoraDate, format?: string, displayOptions?: 'era' | 'calendar' | 'all'): string {
+ if (!(date instanceof KnoraDate)) {
+ console.error('Non-KnoraDate provided. Expected a valid KnoraDate');
+ return '';
+ }
+
+ const formattedString = this.getFormattedString(date, format);
+
+ if (displayOptions) {
+ return this.addDisplayOptions(date, formattedString, displayOptions);
+ } else {
+ return formattedString;
+ }
+ }
+
+ // ensures that day and month are always two digits
+ leftPadding(value: number): string {
+ if (value !== undefined) {
+ return ('0' + value).slice(-2);
+ } else {
+ return null;
+ }
+ }
+
+ // add the era, calendar, or both to the result returned by the pipe
+ addDisplayOptions(date: KnoraDate, value: string, options: string): string {
+ switch (options) {
+ case 'era':
+ return value + (date.era !== 'noEra' ? ' ' + date.era : '');
+ case 'calendar':
+ return value + ' ' + date.calendar;
+ case 'all':
+ return value + (date.era !== 'noEra' ? ' ' + date.era : '') + ' ' + date.calendar;
+ }
+ }
+
+ getFormattedString(date: KnoraDate, format: string): string {
+ switch (format) {
+ case 'dd.MM.YYYY':
+ if (date.precision === 2) {
+ return `${this.leftPadding(date.day)}.${this.leftPadding(date.month)}.${date.year}`;
+ } else if (date.precision === 1) {
+ return `${this.leftPadding(date.month)}.${date.year}`;
+ } else {
+ return `${date.year}`;
+ }
+ case 'dd-MM-YYYY':
+ if (date.precision === 2) {
+ return `${this.leftPadding(date.day)}-${this.leftPadding(date.month)}-${date.year}`;
+ } else if (date.precision === 1) {
+ return `${this.leftPadding(date.month)}-${date.year}`;
+ } else {
+ return `${date.year}`;
+ }
+ case 'MM/dd/YYYY':
+ if (date.precision === 2) {
+ return `${this.leftPadding(date.month)}/${this.leftPadding(date.day)}/${date.year}`;
+ } else if (date.precision === 1) {
+ return `${this.leftPadding(date.month)}/${date.year}`;
+ } else {
+ return `${date.year}`;
+ }
+ default:
+ if (date.precision === 2) {
+ return `${this.leftPadding(date.day)}.${this.leftPadding(date.month)}.${date.year}`;
+ } else if (date.precision === 1) {
+ return `${this.leftPadding(date.month)}.${date.year}`;
+ } else {
+ return `${date.year}`;
+ }
+ }
+ }
+
+}
diff --git a/src/app/main/pipes/string-transformation/linkify.pipe.spec.ts b/src/app/main/pipes/string-transformation/linkify.pipe.spec.ts
new file mode 100644
index 0000000000..dca2367e44
--- /dev/null
+++ b/src/app/main/pipes/string-transformation/linkify.pipe.spec.ts
@@ -0,0 +1,39 @@
+/* eslint-disable max-len */
+import { LinkifyPipe } from './linkify.pipe';
+
+describe('LinkifyPipe', () => {
+
+ let pipe: LinkifyPipe;
+
+ beforeEach(() => {
+ pipe = new LinkifyPipe();
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should recognize the url without protocol but with hashtag at the end', () => {
+ const text = 'You can visit the app on app.dasch.swiss/#';
+ const linkifiedSnippet = pipe.transform(text);
+ expect(linkifiedSnippet).toEqual('You can visit the app on app.dasch.swiss/#');
+ });
+
+ it('should recognize the url with protocol followed by full stop', () => {
+ const text = 'You can visit the app on https://app.dasch.swiss.';
+ const linkifiedSnippet = pipe.transform(text);
+ expect(linkifiedSnippet).toEqual('You can visit the app on https://app.dasch.swiss.');
+ });
+
+ it('should recognize both urls in the example text', () => {
+ const text = 'You can visit the app on https://app.dasch.swiss and the documentation on docs.dasch.swiss.';
+ const linkifiedSnippet = pipe.transform(text);
+ expect(linkifiedSnippet).toEqual('You can visit the app on https://app.dasch.swiss and the documentation on docs.dasch.swiss.');
+ });
+
+ it('should keep the spaces after a full stop or after a comma', () => {
+ const text = 'This is just a title. And it could have an URL, but it doesn\'t have one. ';
+ const linkifiedSnippet = pipe.transform(text);
+ expect(linkifiedSnippet).toEqual('This is just a title. And it could have an URL, but it doesn\'t have one.');
+ });
+});
diff --git a/src/app/main/pipes/string-transformation/linkify.pipe.ts b/src/app/main/pipes/string-transformation/linkify.pipe.ts
new file mode 100644
index 0000000000..b27412e783
--- /dev/null
+++ b/src/app/main/pipes/string-transformation/linkify.pipe.ts
@@ -0,0 +1,57 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+/**
+ * this pipe analyses a string and converts any url into a href tag
+ *
+ */
+@Pipe({
+ name: 'appLinkify'
+})
+export class LinkifyPipe implements PipeTransform {
+
+ transform(value: string): string {
+ let stylizedText = '';
+ if (value && value.length > 0) {
+ for (let str of value.split(' ')) {
+ // if string/url ends with a full stop '.' or colon ':' or comma ',' or semicolon ';' the pipe will not recognize the url
+ const lastChar = str.substring(str.length - 1);
+ const endsWithFullStop = (lastChar === '.' || lastChar === ':' || lastChar === ',' || lastChar === ';');
+ let end = ' ';
+ if (endsWithFullStop) {
+ str = str.slice(0, -1);
+ end = lastChar + ' ';
+ }
+ if (this._recognizeUrl(str)) {
+ const url = this._setProtocol(str);
+ stylizedText += `${str}${end}`;
+ } else {
+ stylizedText += str + end;
+ }
+ }
+ return stylizedText.trim();
+ } else {
+ return value;
+ }
+ }
+
+ private _recognizeUrl(str: string): boolean {
+ const pattern = new RegExp(
+ '^(https?:\\/\\/)?' + // protocol
+ '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
+ '((\\d{1,3}\\.){3}\\d{1,3}))' + // oR ip (v4) address
+ '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
+ '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
+ '(\\#[-a-z\\d_]*)?$',
+ 'i'
+ ); // fragment locator
+ return pattern.test(str);
+ }
+
+ private _setProtocol(url: string): string {
+ if (!/^(?:f|ht)tps?\:\/\//.test(url)) {
+ url = 'http://' + url;
+ }
+ return url;
+ }
+
+}
diff --git a/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.spec.ts b/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.spec.ts
new file mode 100644
index 0000000000..514f191a3c
--- /dev/null
+++ b/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.spec.ts
@@ -0,0 +1,131 @@
+import { waitForAsync, TestBed } from '@angular/core/testing';
+import { StringLiteral } from '@dasch-swiss/dsp-js';
+import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens';
+import { Session, SessionService } from '../../services/session.service';
+import { StringifyStringLiteralPipe } from './stringify-string-literal.pipe';
+
+describe('StringifyStringLiteralPipe', () => {
+ let pipe: StringifyStringLiteralPipe;
+ let labels: StringLiteral[];
+ let service: SessionService;
+
+ beforeEach(waitForAsync(() => {
+
+ // empty spy object to use in the providers for the SessionService injection
+ const dspConnSpy = { };
+
+ TestBed.configureTestingModule({
+ providers: [
+ {
+ provide: DspApiConnectionToken,
+ useValue: dspConnSpy
+ },
+ SessionService
+ ]
+ });
+
+ service = TestBed.inject(SessionService);
+ pipe = new StringifyStringLiteralPipe(service);
+ }));
+
+ beforeEach(() => {
+
+ labels = [
+ {
+ value: 'Welt',
+ language: 'de'
+ },
+ {
+ value: 'World',
+ language: 'en'
+ },
+ {
+ value: 'Monde',
+ language: 'fr'
+ },
+ {
+ value: 'Mondo',
+ language: 'it'
+ },
+ ];
+ });
+
+ // mock localStorage
+ beforeEach(() => {
+ let store = {};
+
+ spyOn(localStorage, 'getItem').and.callFake(
+ (key: string): string => store[key] || null
+ );
+ spyOn(localStorage, 'removeItem').and.callFake(
+ (key: string): void => {
+ delete store[key];
+ }
+ );
+ spyOn(localStorage, 'setItem').and.callFake(
+ (key: string, value: string): void => {
+ store[key] = value;
+ }
+ );
+ spyOn(localStorage, 'clear').and.callFake(() => {
+ store = {};
+ });
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should return a string in English', () => {
+ const session: Session = {
+ id: 12345,
+ user: {
+ name: 'username',
+ jwt: 'myToken',
+ lang: 'en',
+ sysAdmin: false,
+ projectAdmin: []
+ }
+ };
+
+ // store session in localStorage
+ localStorage.setItem('session', JSON.stringify(session));
+
+ // since no argument is provided, the pipe should use the language stored in the session
+ const myString = pipe.transform(labels);
+ expect(myString).toEqual('World');
+
+ // remove session
+ localStorage.removeItem('session');
+ });
+
+ it('should return a string in German', () => {
+ const session: Session = {
+ id: 12345,
+ user: {
+ name: 'username',
+ jwt: 'myToken',
+ lang: 'de',
+ sysAdmin: false,
+ projectAdmin: []
+ }
+ };
+
+ // store session in localStorage
+ localStorage.setItem('session', JSON.stringify(session));
+
+ // since no argument is provided, the pipe should use the language stored in the session
+ const myString = pipe.transform(labels);
+ expect(myString).toEqual('Welt');
+
+ // remove session
+ localStorage.removeItem('session');
+ });
+
+ it('should return a string with all languages of which the StringLiteral array contains', () => {
+
+ // since no argument is provided, the pipe should use the language stored in the session
+ const myString = pipe.transform(labels, 'all');
+ expect(myString).toEqual('Welt (de) / World (en) / Monde (fr) / Mondo (it)');
+ });
+});
diff --git a/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.ts b/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.ts
new file mode 100644
index 0000000000..e983a938af
--- /dev/null
+++ b/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.ts
@@ -0,0 +1,64 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { StringLiteral } from '@dasch-swiss/dsp-js';
+import { SessionService } from '../../services/session.service';
+
+/**
+ * this pipe stringifies an array of StringLiterals.
+ * With the parameter 'all', the pipe concats all values and appends the corresponding language in brackets.
+ *
+ * Otherwise the pipe displays the value corresponding to the default language which
+ * comes from the user profile if a user is logged in or from the browser if the user is not logged in.
+ *
+ * With the predefined language, the pipe checks if a value exists in the array, otherwise it shows the first value.
+ */
+@Pipe({
+ name: 'appStringifyStringLiteral'
+})
+export class StringifyStringLiteralPipe implements PipeTransform {
+
+ constructor(private _sessionService: SessionService) { }
+
+ transform(value: StringLiteral[], args?: string): string {
+ let stringified = '';
+
+ let language: string;
+
+ if (!value || !value.length) {
+ return;
+ }
+
+ if (args === 'all') {
+ // show all values
+ let i = 0;
+ for (const sl of value) {
+ const delimiter = (i > 0 ? ' / ' : '');
+ stringified += delimiter + sl.value + ' (' + sl.language + ')';
+
+ i++;
+ }
+ return stringified;
+ } else {
+ // show only one value, depending on default language
+ // the language is defined in user profile if a user is logged-in
+ // otherwise it takes the language from browser
+ if (this._sessionService.getSession() !== null) {
+ // get language from the logged-in user profile data
+ language = this._sessionService.getSession().user.lang;
+ } else {
+ // get default language from browser
+ language = navigator.language.substr(0, 2);
+ }
+ // does the defined language exists and does it have a value?
+ const index = value.findIndex(i => i.language === language);
+
+ if (value[index] && value[index].value.length > 0) {
+ return value[index].value;
+ } else {
+ return value[0].value;
+ }
+
+ }
+
+ }
+
+}
diff --git a/src/app/main/pipes/string-transformation/truncate.pipe.spec.ts b/src/app/main/pipes/string-transformation/truncate.pipe.spec.ts
new file mode 100644
index 0000000000..9c39bed62a
--- /dev/null
+++ b/src/app/main/pipes/string-transformation/truncate.pipe.spec.ts
@@ -0,0 +1,62 @@
+import { TruncatePipe } from './truncate.pipe';
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+
+describe('TruncatePipe', () => {
+ let pipe: TruncatePipe;
+ let snippet: string;
+
+ beforeEach(() => {
+ pipe = new TruncatePipe();
+ snippet = 'The quick brown fox jumps over the lazy dog.';
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should truncate after 15 characters', () => {
+ const truncatedSnippet = pipe.transform(snippet, 15);
+ expect(truncatedSnippet).toEqual('The quick brown...');
+ });
+
+ it('should truncate after 19 characters and add an exclamation mark at the end', () => {
+ const truncatedSnippet = pipe.transform(snippet, 19, '!');
+ expect(truncatedSnippet).toEqual('The quick brown fox!');
+ });
+
+ it('should truncate after 20 characters by default', () => {
+ const truncatedSnippet = pipe.transform(snippet);
+ expect(truncatedSnippet).toEqual('The quick brown fox ...');
+ });
+
+ it('should support the limit argument in the template', () => {
+ @Component({
+ template: '{{ text | appTruncate:4 }}',
+ })
+ class AppComponent {
+ text = 'This is my string';
+ }
+
+ TestBed.configureTestingModule({ declarations: [AppComponent, TruncatePipe] });
+ const fixture = TestBed.createComponent(AppComponent);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toEqual('This...');
+ });
+
+ it('should support the trail argument in the template', () => {
+ @Component({
+ template: '{{ text | appTruncate:7:\'!!\' }}',
+ })
+ class AppComponent {
+ text = 'This is my string';
+ }
+
+ TestBed.configureTestingModule({ declarations: [AppComponent, TruncatePipe] });
+ const fixture = TestBed.createComponent(AppComponent);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toEqual('This is!!');
+ });
+});
diff --git a/src/app/main/pipes/string-transformation/truncate.pipe.ts b/src/app/main/pipes/string-transformation/truncate.pipe.ts
new file mode 100644
index 0000000000..4118914ab6
--- /dev/null
+++ b/src/app/main/pipes/string-transformation/truncate.pipe.ts
@@ -0,0 +1,45 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+/**
+ * this pipe can be used to shorten long text by a defined length.
+ *
+ * In markup:
+ *
+ * {{ str | appTruncate:24 }}
+ *
+ * or
+ *
+ * {{ str | appTruncate:24:'...' }}
+ *
+ * The first optional parameter defines the length where to truncate the string.
+ * The second optional parameter defines the characters to append to the shortened string. Default is '...'.
+ *
+ * The advantage of this pipe over the default Angular slice pipe is the simplicity of adding
+ * additional characters at the end of the shortened string.
+ * The same construct with Angular slice pipe looks as follow: `{{ (str.length>24)? (str | slice:0:24)+'...':(str) }}`.
+ *
+ */
+@Pipe({
+ name: 'appTruncate'
+})
+export class TruncatePipe implements PipeTransform {
+
+ defaultLimit = 20;
+ defaultTrail = '...';
+
+ transform(value: string, limit?: number, trail?: string): string {
+
+ if (typeof value !== 'string' || value.length === 0) {
+ return '';
+ }
+
+ // if a custom limit was provided, use that. Otherwise, use the default limit of 20 characters
+ const limitSetting: number = limit !== undefined ? limit : this.defaultLimit;
+
+ // if a custom trail was provided, use that. Otherwise, use the default trail of '...'
+ const trailSetting: string = trail !== undefined ? trail : this.defaultTrail;
+
+ return value.length > limitSetting ? value.substring(0, limitSetting) + trailSetting : value;
+ }
+
+}
diff --git a/src/app/main/services/notification.service.spec.ts b/src/app/main/services/notification.service.spec.ts
new file mode 100644
index 0000000000..ea2b7b5699
--- /dev/null
+++ b/src/app/main/services/notification.service.spec.ts
@@ -0,0 +1,29 @@
+import { TestBed } from '@angular/core/testing';
+import { NotificationService } from './notification.service';
+
+describe('NotificationService', () => {
+ let service: NotificationService;
+ const mockNotificationService = jasmine.createSpyObj('NotificationService', ['openSnackBar']);
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: NotificationService, useValue: mockNotificationService }
+ ]
+ });
+ service = TestBed.inject(NotificationService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('openSnackBar', () => {
+ it('should open the snack bar', () => {
+ const arg = 'test';
+ mockNotificationService.openSnackBar.and.callThrough();
+ service.openSnackBar(arg);
+ expect(service.openSnackBar).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/app/main/services/notification.service.ts b/src/app/main/services/notification.service.ts
new file mode 100644
index 0000000000..c361b653dc
--- /dev/null
+++ b/src/app/main/services/notification.service.ts
@@ -0,0 +1,41 @@
+import { Injectable } from '@angular/core';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { ApiResponseError } from '@dasch-swiss/dsp-js';
+import { StatusMsg } from 'src/assets/http/statusMsg';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class NotificationService {
+
+ constructor(
+ private _snackBar: MatSnackBar,
+ private _statusMsg: StatusMsg
+ ) { }
+
+ // todo: maybe we can add more parameters like:
+ // action: string = 'x', duration: number = 4200
+ // and / or type: 'note' | 'warning' | 'error' | 'success'; which can be used for the panelClass
+ openSnackBar(notification: string | ApiResponseError): void {
+ const duration = 5000;
+ let message: string;
+ let panelClass: string;
+
+ if (notification instanceof ApiResponseError) {
+ const status = (notification.status === 0 ? 503 : notification.status);
+ const defaultStatusMsg = this._statusMsg.default;
+ message = `${defaultStatusMsg[status].message} (${status}): ${defaultStatusMsg[status].description}`;
+ panelClass = 'error';
+ } else {
+ message = notification;
+ panelClass = 'success';
+ }
+
+ this._snackBar.open(message, 'x', {
+ duration,
+ horizontalPosition: 'center',
+ verticalPosition: 'top',
+ panelClass
+ });
+ }
+}
diff --git a/src/app/main/services/sorting.service.spec.ts b/src/app/main/services/sorting.service.spec.ts
new file mode 100644
index 0000000000..6b3c6be3f9
--- /dev/null
+++ b/src/app/main/services/sorting.service.spec.ts
@@ -0,0 +1,165 @@
+import { TestBed } from '@angular/core/testing';
+
+import { SortingService } from './sorting.service';
+import { ReadProject } from '@dasch-swiss/dsp-js';
+
+describe('SortingService', () => {
+ let service: SortingService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ SortingService
+ ]
+ });
+
+ service = TestBed.inject(SortingService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('reverseArray', () => {
+
+ let data: string[];
+
+ beforeEach(() => {
+ data = ['Bernouilli', 'Euler', 'Goldbach', 'Hermann'];
+ });
+
+ it('should reverse an array', () => {
+ expect(service.reverseArray(data)).toEqual(['Hermann', 'Goldbach', 'Euler', 'Bernouilli']);
+ });
+ });
+
+ describe('sortByAlphabetical', () => {
+
+ let data:
+ {
+ firstname: string;
+ lastname: string;
+ creator: string;
+ }[];
+
+ beforeEach(() => {
+ data = [
+ {
+ firstname: 'Gaston',
+ lastname: 'Lagaffe',
+ creator: 'André Franquin'
+ },
+ {
+ firstname: 'Mickey',
+ lastname: 'Mouse',
+ creator: 'Walt Disney'
+ },
+ {
+ firstname: 'Gyro',
+ lastname: 'Gearloose',
+ creator: 'Carl Barks'
+ },
+ {
+ firstname: 'Charlie',
+ lastname: 'Brown',
+ creator: 'Charles M. Schulz'
+ }
+ ];
+ });
+
+ it('should return an array sorted by creator', () => {
+ const sorted = service.keySortByAlphabetical(data, 'creator');
+ expect(sorted).toEqual(
+ [
+ { firstname: 'Gaston', lastname: 'Lagaffe', creator: 'André Franquin' },
+ { firstname: 'Gyro', lastname: 'Gearloose', creator: 'Carl Barks' },
+ { firstname: 'Charlie', lastname: 'Brown', creator: 'Charles M. Schulz' },
+ { firstname: 'Mickey', lastname: 'Mouse', creator: 'Walt Disney' }
+ ]);
+ });
+
+ it('should return an array sorted by firstname', () => {
+ const sorted = service.keySortByAlphabetical(data, 'firstname');
+ expect(sorted).toEqual(
+ [
+ { firstname: 'Charlie', lastname: 'Brown', creator: 'Charles M. Schulz' },
+ { firstname: 'Gaston', lastname: 'Lagaffe', creator: 'André Franquin' },
+ { firstname: 'Gyro', lastname: 'Gearloose', creator: 'Carl Barks' },
+ { firstname: 'Mickey', lastname: 'Mouse', creator: 'Walt Disney' }
+ ]);
+ });
+
+ it('should return an array sorted by lastname', () => {
+ const sorted = service.keySortByAlphabetical(data, 'lastname');
+ expect(sorted).toEqual(
+ [
+ { firstname: 'Charlie', lastname: 'Brown', creator: 'Charles M. Schulz' },
+ { firstname: 'Gyro', lastname: 'Gearloose', creator: 'Carl Barks' },
+ { firstname: 'Gaston', lastname: 'Lagaffe', creator: 'André Franquin' },
+ { firstname: 'Mickey', lastname: 'Mouse', creator: 'Walt Disney' }
+ ]);
+ });
+
+ it('should return an array sorted by lastname reversed', () => {
+ const sorted = service.keySortByAlphabetical(data, 'lastname', true);
+ expect(sorted).toEqual(
+ [
+ { firstname: 'Mickey', lastname: 'Mouse', creator: 'Walt Disney' },
+ { firstname: 'Gaston', lastname: 'Lagaffe', creator: 'André Franquin' },
+ { firstname: 'Gyro', lastname: 'Gearloose', creator: 'Carl Barks' },
+ { firstname: 'Charlie', lastname: 'Brown', creator: 'Charles M. Schulz' }
+ ]);
+ });
+
+ });
+
+ describe('Sort an array of ReadProject', () => {
+ let project1: ReadProject;
+ let project2: ReadProject;
+ let project3: ReadProject;
+ let project4: ReadProject;
+ let projects: ReadProject[];
+
+ beforeEach(() => {
+ project1 = new ReadProject();
+ project1.id = '1';
+ project1.longname = 'a';
+
+ project2 = new ReadProject();
+ project2.id = '2';
+ project2.longname = 'b';
+
+ project3 = new ReadProject();
+ project3.id = '3';
+ project3.longname = 'c';
+
+ project4 = new ReadProject();
+ project4.id = '23';
+ project4.longname = 'z';
+
+ projects = [project4, project2, project3, project1];
+ });
+
+ it('should sort an array of ReadProject by "longname"', () => {
+
+ const sorted = service.keySortByAlphabetical(projects, 'longname');
+
+ expect(sorted).toEqual(
+ [project1, project2, project3, project4]
+ );
+
+ });
+
+ it('should sort an array of ReadProject by "longname" reversed', () => {
+
+ const sorted = service.keySortByAlphabetical(projects, 'longname', true);
+
+ expect(sorted).toEqual(
+ [project4, project3, project2, project1]
+ );
+
+ });
+
+ });
+
+});
diff --git a/src/app/main/services/sorting.service.ts b/src/app/main/services/sorting.service.ts
new file mode 100644
index 0000000000..a3b1cd8c09
--- /dev/null
+++ b/src/app/main/services/sorting.service.ts
@@ -0,0 +1,37 @@
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class SortingService {
+
+ constructor() { }
+
+ /**
+ * reverses an array
+ */
+ reverseArray(value: Array): Array {
+ return value.slice().reverse();
+ }
+
+ /**
+ * compares value by value and sorts in alphabetical order using the provided key
+ * optionally, you can have the array returned to you in reversed order by setting the reversed parameter to 'true'
+ */
+ keySortByAlphabetical(value: Array, sortKey: keyof T, reversed = false): Array {
+ const sortedArray = value.slice();
+ sortedArray.sort((a: T, b: T) => {
+ if (String(a[sortKey]).toLowerCase() < String(b[sortKey]).toLowerCase()) {
+ return -1;
+ } else if (String(a[sortKey]).toLowerCase() > String(b[sortKey]).toLowerCase()) {
+ return 1;
+ } else {
+ return 0;
+ }
+ });
+ if (reversed) {
+ sortedArray.reverse();
+ }
+ return sortedArray;
+ }
+}
diff --git a/src/assets/http/statusMsg.ts b/src/assets/http/statusMsg.ts
new file mode 100644
index 0000000000..a13f13eaac
--- /dev/null
+++ b/src/assets/http/statusMsg.ts
@@ -0,0 +1,263 @@
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class StatusMsg {
+
+ default: any = {
+ 100: {
+ message: 'Continue',
+ description: 'The server has received the request headers, and the client should proceed to send the request body'
+ },
+ 101: {
+ message: 'Switching Protocols',
+ description: 'The requester has asked the server to switch protocols'
+ },
+ 103: {
+ message: 'Checkpoint',
+ description: 'Used in the resumable requests proposal to resume aborted PUT or POST requests'
+ },
+ 200: {
+ message: 'OK',
+ description: 'The request is OK (this is the standard response for successful HTTP requests)'
+ },
+ 201: {
+ message: 'Created',
+ description: 'The request has been fulfilled, and a new resource is created'
+ },
+ 202: {
+ message: 'Accepted',
+ description: 'The request has been accepted for processing, but the processing has not been completed'
+ },
+ 203: {
+ message: 'Non-Authoritative Information',
+ description: 'The request has been successfully processed, but is returning information that may be from another source'
+ },
+ 204: {
+ message: 'No Content',
+ description: 'The request has been successfully processed, but is not returning any content'
+ },
+ 205: {
+ message: 'Reset Content',
+ description: 'The request has been successfully processed, but is not returning any content, and requires that the requester reset the document view'
+ },
+ 206: {
+ message: 'Partial Content',
+ description: 'The server is delivering only part of the resource due to a range header sent by the client'
+ },
+ 300: {
+ message: 'Multiple Choices',
+ description: 'A link list. The user can select a link and go to that location. Maximum five addresses'
+ },
+ 301: {
+ message: 'Moved Permanently',
+ description: 'The requested page has moved to a new URL'
+ },
+ 302: {
+ message: 'Found',
+ description: 'The requested page has moved temporarily to a new URL'
+ },
+ 303: {
+ message: 'See Other',
+ description: 'The requested page can be found under a different URL'
+ },
+ 304: {
+ message: 'Not Modified',
+ description: 'Indicates the requested page has not been modified since last requested'
+ },
+ 306: {
+ message: 'Switch Proxy',
+ description: '-- No longer used --'
+ },
+ 307: {
+ message: 'Temporary Redirect',
+ description: 'The requested page has moved temporarily to a new URL'
+ },
+ 308: {
+ message: 'Resume Incomplete',
+ description: 'Used in the resumable requests proposal to resume aborted PUT or POST requests'
+ },
+ 400: {
+ message: 'Bad Request',
+ description: 'The request cannot be fulfilled due to bad syntax'
+ },
+ 401: {
+ message: 'Unauthorized',
+ description: 'The request was a legal request, but the server is refusing to respond to it. For use when authentication is possible but has failed or not yet been provided'
+ },
+ 402: {
+ message: 'Payment Required',
+ description: '-- Reserved for future use --'
+ },
+ 403: {
+ message: 'Forbidden',
+ description: 'The request was a legal request, but the server is refusing to respond to it'
+ },
+ 404: {
+ message: 'Not Found',
+ description: 'The requested page could not be found but may be available again in the future'
+ },
+ 405: {
+ message: 'Method Not Allowed',
+ description: 'A request was made of a page using a request method not supported by that page'
+ },
+ 406: {
+ message: 'Not Acceptable',
+ description: 'The server can only generate a response that is not accepted by the client'
+ },
+ 407: {
+ message: 'Proxy Authentication Required',
+ description: 'The client must first authenticate itself with the proxy'
+ },
+ 408: {
+ message: 'Request Timeout',
+ description: 'The server timed out waiting for the request'
+ },
+ 409: {
+ message: 'Conflict',
+ description: 'The request could not be completed because of a conflict in the request'
+ },
+ 410: {
+ message: 'Gone',
+ description: 'The requested page is no longer available'
+ },
+ 411: {
+ message: 'Length Required',
+ description: 'The "Content-Length" is not defined. The server will not accept the request without it'
+ },
+ 412: {
+ message: 'Precondition Failed',
+ description: 'The precondition given in the request evaluated to false by the server'
+ },
+ 413: {
+ message: 'Request Entity Too Large',
+ description: 'The server will not accept the request, because the request entity is too large'
+ },
+ 414: {
+ message: 'Request-URI Too Long',
+ description: 'The server will not accept the request, because the URL is too long. Occurs when you convert a POST request to a GET request with a long query information'
+ },
+ 415: {
+ message: 'Unsupported Media Type',
+ description: 'The server will not accept the request, because the media type is not supported'
+ },
+ 416: {
+ message: 'Requested Range Not Satisfiable',
+ description: 'The client has asked for a portion of the file, but the server cannot supply that portion'
+ },
+ 417: {
+ message: 'Expectation Failed',
+ description: 'The server cannot meet the requirements of the Expect request-header field'
+ },
+ 418: {
+ message: 'I\'m a teapot',
+ description: 'Any attempt to brew coffee with a teapot should result in the error code "418 I\'m a teapot". The resulting entity body MAY be short and stout'
+ },
+ 421: {
+ message: 'Misdirected Request',
+ description: 'The request was directed at a server that is not able to produce a response (for example because a connection reuse)'
+ },
+ 422: {
+ message: 'Unprocessable Entity',
+ description: 'The request was well-formed but was unable to be followed due to semantic errors'
+ },
+ 423: {
+ message: 'Locked',
+ description: 'The resource that is being accessed is locked'
+ },
+ 424: {
+ message: 'Failed Dependency',
+ description: 'The request failed due to failure of a previous request (e.g., a PROPPATCH)'
+ },
+ 426: {
+ message: 'Upgrade Required',
+ description: 'The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field'
+ },
+ 428: {
+ message: 'Precondition Required',
+ description: 'The origin server requires the request to be conditional'
+ },
+ 429: {
+ message: 'Too Many Requests',
+ description: 'The user has sent too many requests in a given amount of time. Intended for use with rate limiting schemes'
+ },
+ 431: {
+ message: 'Request Header Fields Too Large',
+ description: 'The server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large'
+ },
+ 451: {
+ message: 'Unavailable For Legal Reasons',
+ description: 'A server operator has received a legal demand to deny access to a resource or to a set of resources that includes the requested resource'
+ },
+ 500: {
+ message: 'Internal Server Error',
+ description: 'An error has occured in a server side script, a no more specific message is suitable'
+ },
+ 501: {
+ message: 'Not Implemented',
+ description: 'The server either does not recognize the request method, or it lacks the ability to fulfill the request'
+ },
+ 502: {
+ message: 'Bad Gateway',
+ description: 'The server was acting as a gateway or proxy and received an invalid response from the upstream server'
+ },
+ 503: {
+ message: 'Service Unavailable',
+ description: 'The server is currently unavailable (overloaded or down)'
+ },
+ 504: {
+ message: 'Gateway Timeout',
+ description: 'The server was acting as a gateway or proxy and did not receive a timely response from the upstream server'
+ },
+ 505: {
+ message: 'HTTP Version Not Supported',
+ description: 'The server does not support the HTTP protocol version used in the request'
+ },
+ 511: {
+ message: 'Network Authentication Required',
+ description: 'The client needs to authenticate to gain network access'
+ },
+ info: {
+ lastUpdate: {
+ date: '20160411',
+ log: '4xx Codes updated via Wikipedia'
+ },
+ references: {
+ W3Schools: 'http://www.w3schools.com/tags/ref_httpmessages.asp',
+ Wikipedia: 'https://en.wikipedia.org/wiki/List_of_HTTP_status_codes'
+ },
+ codeLookup: {
+ info: 'Use the \'code\' variable in the url to lookup and individual code',
+ demoURL: '/statusMsg/?code=500'
+ },
+ htmlDisplay: {
+ info: 'Add the \'html\' variable to your code lookup to get the error displayed in a nice html template',
+ demoURL: '/statusMsg/?code=404&html'
+ },
+ invalidCode: 'If an invalid code is given, the site will just show the json list of all codes',
+ credits: 'This site was crafted by Unreal Designs and is powered by UDCDN'
+ },
+ '1xx': {
+ message: 'Information',
+ description: ''
+ },
+ '2xx': {
+ message: 'Successful',
+ description: ''
+ },
+ '3xx': {
+ message: 'Redirection',
+ description: ''
+ },
+ '4xx': {
+ message: 'Client Error',
+ description: ''
+ },
+ '5xx': {
+ message: 'Server Error',
+ description: ''
+ }
+ };
+
+}
diff --git a/src/assets/style/_viewer.scss b/src/assets/style/_viewer.scss
new file mode 100644
index 0000000000..487cdee173
--- /dev/null
+++ b/src/assets/style/_viewer.scss
@@ -0,0 +1,213 @@
+@import "./config";
+@import "./mixins";
+@import "./responsive";
+
+// css for parent value components
+
+.read-mode-view {
+ font: 400 15px/24px sans-serif;
+ .rm-value, .rm-comment {
+ display: block;
+ margin: 0px;
+ }
+}
+
+.child-value-component,
+.value-component-comment {
+ display: block;
+}
+
+.parent-component-wrapper .value-component-comment {
+ margin-top: -2em;
+}
+
+.parent-value-component .child-value-component .mat-form-field-wrapper .mat-form-field-infix {
+ border-top: 0em solid transparent;
+}
+
+
+// css for child components of a parent value component
+
+.child-input-component {
+ display: inline-block;
+ vertical-align: bottom;
+ width: 49%;
+ &:nth-child(2) {
+ padding-left: 2%;
+ }
+}
+
+.child-input-component.full-width {
+ width: 100%;
+}
+
+// custom error message
+
+.custom-error-message {
+ font-size: 12px;
+ margin-top: -1em;
+ padding-bottom: 1em;
+}
+
+// viewer operations
+
+.grid-container {
+ display: grid;
+ grid-template-columns: 70% 30%;
+ grid-template-rows: auto auto;
+}
+
+.value-component,
+.crud-buttons {
+ vertical-align: top;
+}
+
+.value-component {
+ grid-row-start: 1;
+ grid-row-end: auto;
+ grid-column-start: 1;
+ grid-column-end: 2;
+ position: relative;
+
+ .action-bubble {
+ position: absolute;
+ right: 0;
+ top: -20px;
+ border: 1px solid #e4e4e4;
+ border-radius: 10px;
+ padding: 0 1px;
+ background-color: #e4e4e4;
+ z-index: 2;
+ box-shadow: #949494 1px 4px 5px 0px;
+
+ .button-container {
+
+ button.mat-button-disabled {
+
+ .mat-icon {
+ color: #aaaaaa;
+ }
+
+ }
+
+ button {
+ cursor: pointer;
+ border: none;
+ padding: 2px;
+ outline: none;
+ background-color: transparent;
+ color: #000000;
+ margin: 0 2px;
+ border-radius: 10px;
+ transition: background-color ease-out 0.5s;
+ min-width: inherit;
+ line-height: normal;
+
+ .material-icons {
+ font-size: 18px;
+ }
+
+ .mat-icon {
+ width: 18px;
+ height: 18px;
+ vertical-align: middle;
+ }
+ }
+
+ button.info {
+ cursor: default;
+ }
+
+ button:hover {
+ background-color: #c7c7c7;
+ }
+ }
+ }
+}
+
+.value-component.highlighted {
+ background-color: #f5f5f5;
+}
+
+.crud-buttons {
+ grid-row-start: 1;
+ grid-row-end: auto;
+ grid-column-start: 2;
+ grid-column-end: 3;
+}
+
+.mat-error {
+ grid-row-start: 1;
+ grid-row-end: auto;
+ grid-column-start: 3;
+ grid-column-end: end;
+}
+
+button.save,
+button.cancel {
+ cursor: pointer;
+ border: none;
+ padding: 0em;
+ margin-left: 1em;
+ outline: none;
+ background-color: transparent;
+ color: #000000;
+}
+
+button.save:disabled {
+ color: #adadad;
+ cursor: default;
+}
+
+button.save .material-icons,
+button.cancel .material-icons {
+ font-size: 18px;
+}
+
+button.save .mat-icon,
+button.cancel .mat-icon {
+ font-size: 18px;
+}
+
+.deletion-dialog .title {
+ font-weight: bold;
+ font-size: 18px;
+ text-align: center;
+}
+
+.deletion-dialog .action-buttons {
+ float: right;
+
+ .cancel {
+ padding: 0 16px;
+ }
+}
+
+.deletion-dialog-message {
+ font-size: 16px;
+ background-color: #ededf5;
+ border: 1px solid #d8d8df;
+ border-radius: 5px;
+ padding: 20px 10px 20px 10px;
+ text-align: center;
+}
+
+.deletion-dialog-message .deletion-comment {
+ min-width: 80%;
+}
+
+
+.ck-content code {
+ font-family: monospace !important;
+}
+
+.ck-content pre {
+ background-color: inherit !important;
+}
+
+code, pre {
+ font-family: monospace !important;
+ background-color: hsla(0,0%,78%,.3);
+ padding: .15em;
+ border-radius: 2px;
+}