intermediate commit for tests

This commit is contained in:
lotte
2019-02-08 14:42:34 +01:00
parent 0050f58bf0
commit ace523ed14
35 changed files with 1560 additions and 270 deletions

View File

@@ -79,5 +79,8 @@ module.exports = {
code: 'nl',
label: 'Nederlands',
active: false,
}]
}],
item: {
}
};

View File

@@ -223,7 +223,26 @@
"error": "An error occured while deleting the item"
},
"metadata": {
"add-button": "Add new metadata"
"add-button": "Add",
"discard-button": "Discard",
"reinstate-button": "Undo",
"save-button": "Save",
"headers": {
"field": "Field",
"value": "Value",
"language": "Lang",
"edit": "Edit"
},
"notifications": {
"outdated": {
"title": "Changed outdated",
"content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts"
},
"discarded": {
"title": "Changed discarded",
"content": "Your changes were discarded. To reinstate your changes click the 'Undo' button"
}
}
}
}
},

View File

@@ -14,9 +14,10 @@
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.metadata.head' | translate}}">
<ngb-tab [id]="'metadata'" title="{{'item.edit.tabs.metadata.head' | translate}}">
<ng-template ngbTabContent>
<ds-item-metadata [item]="(itemRD$ | async)?.payload"></ds-item-metadata>
<ds-item-metadata [item]="(itemRD$ | async)?.payload">
</ds-item-metadata>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.view.head' | translate}}">

View File

@@ -13,7 +13,7 @@ import {ItemPrivateComponent} from './item-private/item-private.component';
import {ItemPublicComponent} from './item-public/item-public.component';
import {ItemDeleteComponent} from './item-delete/item-delete.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { EditInPlaceComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
/**
* Module that contains all components related to the Edit Item page administrator functionality
@@ -36,7 +36,7 @@ import { EditInPlaceComponent } from './item-metadata/edit-in-place-field/edit-i
ItemDeleteComponent,
ItemStatusComponent,
ItemMetadataComponent,
EditInPlaceComponent
EditInPlaceFieldComponent
]
})
export class EditItemPageModule {

View File

@@ -1,42 +1,47 @@
<td class="col-3">
<!--<div *ngIf="!editable">-->
<span>{{metadata.key}}</span>
<!--</div>-->
<!--<div *ngIf="editable" class="field-container">-->
<!--<ds-input-suggestions [suggestions]="(filterSearchResults | async)"-->
<!--[action]="getCurrentUrl()"-->
<!--[name]="filterConfig.paramName"-->
<!--[(ngModel)]="metadata.key"-->
<!--(submitSuggestion)="updateField($event)"-->
<!--(clickSuggestion)="updateField($event)"-->
<!--(findSuggestions)="findSuggestions($event)"-->
<!--ngDefaultControl-->
<!--&gt;</ds-input-suggestions>-->
<!--</div>-->
</td>
<td class="col-7">
<div *ngIf="!editable">
<span>{{metadata.value}}</span>
</div>
<div *ngIf="editable" class="field-container">
<textarea type="textarea" [ngModel]="metadata.value" [dsDebounce] (onDebounce)="update()"></textarea>
</div>
</td>
<td class="col-1">
<div *ngIf="!editable">
<span>{{metadata.language}}</span>
</div>
<div *ngIf="editable" class="field-container">
<input type="text" [ngModel]="metadata.language" [dsDebounce] (onDebounce)="update()"/>
</div>
</td>
<td class="col-1">
<div *ngIf="!editable">
<i class="fas fa-edit fa-fw" (click)="editable = !editable"></i>
<i class="fas fa-trash fa-fw" (click)="remove()"></i>
</div>
<div *ngIf="editable">
<i class="fas fa-times fa-fw" (click)="editable = !editable"></i>
<i class="fas fa-trash fa-fw" (click)="remove()"></i>
</div>
</td>
<div [ngClass]="{
'table-warning': fieldUpdate.changeType === 0,
'table-danger': fieldUpdate.changeType === 2,
'table-success': fieldUpdate.changeType === 1
}" class="d-flex">
<!--{{metadata?.uuid}}-->
<td class="col-3">
<div *ngIf="!(editable | async)">
<span>{{metadata?.key}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<ds-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key"
(submitSuggestion)="update()"
(clickSuggestion)="update()"
(findSuggestions)="findMetadataFieldSuggestions($event)"
ngDefaultControl
></ds-input-suggestions>
</div>
</td>
<td class="col-7">
<div *ngIf="!(editable | async)">
<span>{{metadata?.value}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<textarea class="form-control" type="textarea" [(ngModel)]="metadata.value" [dsDebounce]
(onDebounce)="update()"></textarea>
</div>
</td>
<td class="col-1 text-center">
<div *ngIf="!(editable | async)">
<span>{{metadata?.language}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<input class="form-control" type="text" [(ngModel)]="metadata.language" [dsDebounce]
(onDebounce)="update()"/>
</div>
</td>
<td class="col-1 text-center">
<div>
<i *ngIf="canSetEditable() | async" class="fas fa-edit fa-fw text-primary" (click)="setEditable(true)"></i>
<i *ngIf="canSetUneditable() | async" class="fas fa-check fa-fw text-success" (click)="setEditable(false)"></i>
<i *ngIf="canRemove() | async" class="fas fa-trash-alt fa-fw text-danger" (click)="remove()"></i>
<i *ngIf="canUndo() | async" class="fas fa-undo-alt fa-fw text-warning" (click)="removeChangesFromField()"></i>
</div>
</td>
</div>

View File

@@ -1,3 +0,0 @@
textarea, input, select {
width: 100%;
}

View File

@@ -0,0 +1,290 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { EditInPlaceFieldComponent } from './edit-in-place-field.component';
import { RegistryService } from '../../../../core/registry/registry.service';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { MetadataField } from '../../../../core/metadata/metadatafield.model';
import { By } from '@angular/platform-browser';
import { Metadatum } from '../../../../core/shared/metadatum.model';
import { FormsModule } from '@angular/forms';
import { SharedModule } from '../../../../shared/shared.module';
import { getTestScheduler } from 'jasmine-marbles';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { TestScheduler } from 'rxjs/testing';
import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture<EditInPlaceFieldComponent>;
let de: DebugElement;
let el: HTMLElement;
let metadataFieldService;
let objectUpdatesService;
let paginatedMetadataFields;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' })
const mdField1 = Object.assign(new MetadataField(), {
schema: mdSchema,
element: 'contributor',
qualifier: 'author'
});
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
const mdField3 = Object.assign(new MetadataField(), {
schema: mdSchema,
element: 'description',
qualifier: 'abstract'
});
const metadatum = Object.assign(new Metadatum(), {
key: 'dc.description.abstract',
value: 'Example abstract',
language: 'en'
});
const route = 'http://test-url.com/test-url';
const fieldUpdate = {
field: metadatum,
changeType: undefined
};
let scheduler: TestScheduler;
describe('EditInPlaceFieldComponent', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]);
metadataFieldService = jasmine.createSpyObj({
queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields))
});
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
saveChangeFieldUpdate: {},
saveRemoveFieldUpdate: {},
setEditableFieldUpdate: {},
removeSingleFieldUpdate: {},
isEditable: observableOf(false) // should always return something --> its in ngOnInit
}
);
TestBed.configureTestingModule({
imports: [FormsModule, SharedModule],
declarations: [EditInPlaceFieldComponent],
providers: [
{ provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditInPlaceFieldComponent);
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
de = fixture.debugElement.query(By.css('div.d-flex'));
el = de.nativeElement;
comp.route = route;
comp.fieldUpdate = fieldUpdate;
comp.metadata = metadatum;
fixture.detectChanges();
});
describe('update', () => {
beforeEach(() => {
comp.update();
});
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(route, metadatum);
});
});
describe('setEditable', () => {
const editable = false;
beforeEach(() => {
comp.setEditable(editable);
});
it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct route and uuid and false', () => {
expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(route, metadatum.uuid, editable);
});
});
describe('remove', () => {
beforeEach(() => {
comp.remove();
});
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(route, metadatum);
});
});
describe('remove', () => {
beforeEach(() => {
comp.remove();
});
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(route, metadatum);
});
});
describe('findMetadataFieldSuggestions', () => {
const query = 'query string';
const metadataFieldSuggestions: InputSuggestion[] =
[
{ displayValue: mdField1.toString(), value: mdField1.toString() },
{ displayValue: mdField2.toString(), value: mdField2.toString() },
{ displayValue: mdField3.toString(), value: mdField3.toString() }
];
beforeEach(() => {
comp.findMetadataFieldSuggestions(query);
});
it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => {
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query);
});
it('it should set metadataFieldSuggestions to the right value', () => {
const expected = 'a';
scheduler.expectObservable(comp.metadataFieldSuggestions).toBe(expected, { a: metadataFieldSuggestions });
});
});
describe('canSetEditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
});
it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false });
});
});
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
});
describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
});
it('canSetEditable should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
});
it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false });
});
})
});
});
describe('canSetUneditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
});
it('canSetUneditable should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: true });
});
});
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
});
it('canSetUneditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: false });
});
});
});
describe('canRemove', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
});
it('canRemove should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false });
});
});
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
});
describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
});
it('canRemove should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
});
it('canRemove should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false });
});
})
});
});
describe('canUndo', () => {
describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
});
it('canUndo should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently undefined', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = undefined;
});
it('canUndo should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false });
});
});
});
});

View File

@@ -1,41 +1,169 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { isNotEmpty } from '../../../../shared/empty.util';
import { Metadatum } from '../../../../core/shared/metadatum.model';
import { RegistryService } from '../../../../core/registry/registry.service';
import { cloneDeep } from 'lodash';
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { BehaviorSubject, Observable } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { MetadataField } from '../../../../core/metadata/metadatafield.model';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { of as observableOf } from 'rxjs';
@Component({
selector: 'ds-edit-in-place-field.',
selector: 'ds-edit-in-place-field',
styleUrls: ['./edit-in-place-field.component.scss'],
templateUrl: './edit-in-place-field.component.html',
})
/**
* Component for displaying an item's status
* Component that displays a single metadatum of an item on the edit page
*/
export class EditInPlaceComponent {
export class EditInPlaceFieldComponent implements OnInit, OnChanges {
/**
* The value to display
* The current field, value and state of the metadatum
*/
@Input() metadata: Metadatum;
@Output() mdUpdate: EventEmitter<any> = new EventEmitter();
@Output() mdRemove: EventEmitter<any> = new EventEmitter();
editable = false;
@Input() fieldUpdate: FieldUpdate;
/**
* The current route of this page
*/
@Input() route: string;
/**
* The metadatum of this field
*/
metadata: Metadatum;
/**
* Emits whether or not this field is currently editable
*/
editable: Observable<boolean>;
/**
* The current suggestions for the metadatafield when editing
*/
metadataFieldSuggestions: BehaviorSubject<InputSuggestion[]> = new BehaviorSubject([]);
constructor(
private metadataFieldService: RegistryService,
private objectUpdatesService: ObjectUpdatesService,
) {
}
isNotEmpty(value) {
/**
* Sends a new change update for this field to the object updates service
*/
update() {
this.objectUpdatesService.saveChangeFieldUpdate(this.route, this.metadata);
}
/**
* Sends a new editable state for this field to the service to change it
* @param editable The new editable state for this field
*/
setEditable(editable: boolean) {
this.objectUpdatesService.setEditableFieldUpdate(this.route, this.metadata.uuid, editable);
}
/**
* Sends a new remove update for this field to the object updates service
*/
remove() {
this.objectUpdatesService.saveRemoveFieldUpdate(this.route, this.metadata);
}
/**
* Notifies the object updates service that the updates for the current field can be removed
*/
removeChangesFromField() {
this.objectUpdatesService.removeSingleFieldUpdate(this.route, this.metadata.uuid);
}
/**
* Sets up an observable that keeps track of the current editable state of this field
*/
ngOnInit(): void {
this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid);
}
/**
* Sets the current metadatafield based on the fieldUpdate input field
*/
ngOnChanges(): void {
this.metadata = cloneDeep(this.fieldUpdate.field) as Metadatum;
}
/**
* Requests all metadata fields that contain the query string in their key
* Then sets all found metadata fields as metadataFieldSuggestions
* @param query The query to look for
*/
findMetadataFieldSuggestions(query: string): void {
this.metadataFieldService.queryMetadataFields(query).pipe(
// getSucceededRemoteData(),
take(1),
map((data) => data.payload.page)
).subscribe(
(fields: MetadataField[]) => this.metadataFieldSuggestions.next(
fields.map((field: MetadataField) => {
return {
displayValue: field.toString(),
value: field.toString()
}
})
)
);
}
/**
* Check if a user should be allowed to edit this field
* @return an observable that emits true when the user should be able to edit this field and false when they should not
*/
canSetEditable(): Observable<boolean> {
return this.editable.pipe(
map((editable: boolean) => {
if (editable) {
return false;
} else {
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE;
}
})
);
}
/**
* Check if a user should be allowed to disabled editing this field
* @return an observable that emits true when the user should be able to disable editing this field and false when they should not
*/
canSetUneditable(): Observable<boolean> {
return this.editable;
}
/**
* Check if a user should be allowed to remove this field
* @return an observable that emits true when the user should be able to remove this field and false when they should not
*/
canRemove(): Observable<boolean> {
return this.editable.pipe(
map((editable: boolean) => {
if (editable) {
return false;
} else {
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD;
}
})
);
}
/**
* Check if a user should be allowed to undo changes to this field
* @return an observable that emits true when the user should be able to undo changes to this field and false when they should not
*/
canUndo(): Observable<boolean> {
return observableOf(this.fieldUpdate.changeType >= 0);
}
protected isNotEmpty(value): boolean {
return isNotEmpty(value);
}
update() {
this.mdUpdate.emit();
}
remove() {
this.mdRemove.emit()
}
}

View File

@@ -1,12 +1,55 @@
<div class="container">
<div class="item-metadata">
<button class="btn btn-primary w-100 my-2" (click)="update()">{{"item.edit.metadata.add-button" | translate}}</button>
<table class="table table-responsive table-striped">
<div class="button-row d-flex justify-content-between">
<button class="btn btn-success my-2"
(click)="add()"><i
class="fas fa-plus"></i> {{"item.edit.metadata.add-button" | translate}}
</button>
<div class="btn-group btn-group-toggle my-2" data-toggle="buttons">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
</div>
</div>
<table class="table table-responsive table-striped table-bordered">
<tbody>
<tr *ngFor="let metadatum of item.metadata; let i=index">
<ds-edit-in-place-field [metadata]="metadatum" class="d-flex" (mdUpdate)="update()" (mdRemove)="update()"></ds-edit-in-place-field>
<tr class="d-flex">
<th class="col-3">{{'item.edit.metadata.headers.field' | translate}}</th>
<th class="col-7">{{'item.edit.metadata.headers.value' | translate}}</th>
<th class="col-1 text-center">{{'item.edit.metadata.headers.language' | translate}}</th>
<th class="col-1 text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
</tr>
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate">
<ds-edit-in-place-field [fieldUpdate]="updateValue || {}"
[route]="route"></ds-edit-in-place-field>
</tr>
</tbody>
</table>
<div class="btn-group btn-group-toggle my-2 float-right" data-toggle="buttons">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,199 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { getTestScheduler } from 'jasmine-marbles';
import { ItemMetadataComponent } from './item-metadata.component';
import { Metadatum } from '../../../core/shared/metadatum.model';
import { TestScheduler } from 'rxjs/testing';
import { SharedModule } from '../../../shared/shared.module';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ItemDataService } from '../../../core/data/item-data.service';
import { By } from '@angular/platform-browser';
import {
INotification,
Notification
} from '../../../shared/notifications/models/notification.model';
import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { RouterStub } from '../../../shared/testing/router-stub';
import { GLOBAL_CONFIG } from '../../../../config';
import { Item } from '../../../core/shared/item.model';
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
import { RemoteData } from '../../../core/data/remote-data';
let comp: ItemMetadataComponent;
let fixture: ComponentFixture<ItemMetadataComponent>;
let de: DebugElement;
let el: HTMLElement;
let objectUpdatesService;
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
const date = new Date();
const router = new RouterStub();
let itemService;
const notificationsService = jasmine.createSpyObj('notificationsService',
{
info: infoNotification,
warning: warningNotification
}
);
const metadatum1 = Object.assign(new Metadatum(), {
key: 'dc.description.abstract',
value: 'Example abstract',
language: 'en'
});
const metadatum2 = Object.assign(new Metadatum(), {
key: 'dc.title',
value: 'Title test',
language: 'de'
});
const metadatum3 = Object.assign(new Metadatum(), {
key: 'dc.contributor.author',
value: 'Shakespeare, William',
});
const route = 'http://test-url.com/test-url';
router.url = route;
const fieldUpdate1 = {
field: metadatum1,
changeType: undefined
};
const fieldUpdate2 = {
field: metadatum2,
changeType: FieldChangeType.REMOVE
};
const fieldUpdate3 = {
field: metadatum3,
changeType: undefined
};
let scheduler: TestScheduler;
let item;
describe('ItemMetadataComponent', () => {
beforeEach(async(() => {
item = Object.assign(new Item(), { metadata: [metadatum1, metadatum2, metadatum3] }, { lastModified: date });
itemService = jasmine.createSpyObj('itemService', {
update: observableOf(new RemoteData(false, false, true, undefined, item))
});
scheduler = getTestScheduler();
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
getFieldUpdates: observableOf({
[metadatum1.uuid]: fieldUpdate1,
[metadatum2.uuid]: fieldUpdate2,
[metadatum3.uuid]: fieldUpdate3
}),
saveAddFieldUpdate: {},
discardFieldUpdates: {},
reinstateFieldUpdates: observableOf(true),
initialize: {},
getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]),
getLastModified: observableOf(date),
hasUpdates: observableOf(true),
isReinstatable: observableOf(false) // should always return something --> its in ngOnInit
}
);
TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()],
declarations: [ItemMetadataComponent],
providers: [
{ provide: ItemDataService, useValue: itemService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: Router, useValue: router },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: GLOBAL_CONFIG, useValue: { notifications: { timeOut: 10 } } as any }
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
})
)
;
beforeEach(() => {
fixture = TestBed.createComponent(ItemMetadataComponent);
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
de = fixture.debugElement.query(By.css('div.d-flex'));
el = de.nativeElement;
comp.item = item;
comp.route = route;
fixture.detectChanges();
});
describe('add', () => {
const md = new Metadatum();
beforeEach(() => {
comp.add(md);
});
it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(route, md);
});
});
describe('discard', () => {
beforeEach(() => {
comp.discard();
});
it('it should call discardFieldUpdates on the objectUpdatesService with the correct route and notification', () => {
expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(route, infoNotification);
});
});
describe('reinstate', () => {
beforeEach(() => {
comp.reinstate();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct route', () => {
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(route);
});
});
describe('submit', () => {
beforeEach(() => {
comp.submit();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(route, comp.item.metadata);
expect(itemService.update).toHaveBeenCalledWith(comp.item);
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(route, comp.item.metadata);
});
});
describe('hasChanges', () => {
describe('when the objectUpdatesService\'s hasUpdated method returns true', () => {
beforeEach(() => {
objectUpdatesService.hasUpdates.and.returnValue(observableOf(true));
});
it('should return an observable that emits true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: true });
});
});
describe('when the objectUpdatesService\'s hasUpdated method returns false', () => {
beforeEach(() => {
objectUpdatesService.hasUpdates.and.returnValue(observableOf(false));
});
it('should return an observable that emits false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: false });
});
});
});
})
;

View File

@@ -1,40 +1,165 @@
import { Component, Input } from '@angular/core';
import { Component, Inject, Input, OnInit } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { Router } from '@angular/router';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
import {
FieldUpdate,
FieldUpdates,
Identifiable
} from '../../../core/data/object-updates/object-updates.reducer';
import { Metadatum } from '../../../core/shared/metadatum.model';
import { DSOChangeAnalyzer } from '../../../core/data/dso-change-analyzer.service';
import { first, switchMap } from 'rxjs/operators';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'ds-item-metadata',
templateUrl: './item-metadata.component.html',
})
/**
* Component for displaying an item's status
* Component for displaying an item's metadata edit page
*/
export class ItemMetadataComponent {
export class ItemMetadataComponent implements OnInit {
/**
* The item to display the metadata for
* The item to display the edit page for
*/
@Input() item: Item;
updateItem: Item;
constructor(private itemService: ItemDataService, private dsoChanges: DSOChangeAnalyzer<Item>) {
this.updateItem = Object.assign({}, this.item);
/**
* The current values and updates for all this item's metadata fields
*/
updates$: Observable<FieldUpdates>;
/**
* The current route of this page
*/
route: string;
/**
* The time span for being able to undo discarding changes
*/
private discardTimeOut: number;
constructor(
private itemService: ItemDataService,
private objectUpdatesService: ObjectUpdatesService,
private router: Router,
private notificationsService: NotificationsService,
private translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig
) {
}
update() {
this.dsoChanges.diff(this.item, this.updateItem);
ngOnInit(): void {
this.discardTimeOut = this.EnvConfig.notifications.timeOut;
this.route = this.router.url;
if (this.route.indexOf('?') > 0) {
this.route = this.route.substr(0, this.route.indexOf('?'));
}
this.hasChanges().pipe(first()).subscribe((hasChanges) => {
if (!hasChanges) {
this.initializeOriginalFields();
} else {
this.checkLastModified();
}
});
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata);
}
// submit() {
//
// }
//
// discard() {
//
// }
//
// undo() {
//
// }
/**
* Sends a new add update for a field to the object updates service
* @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum
*/
add(metadata: Metadatum = new Metadatum()) {
this.objectUpdatesService.saveAddFieldUpdate(this.route, metadata);
}
/**
* Request the object updates service to discard all current changes to this item
* Shows a notification to remind the user that they can undo this
*/
discard() {
const title = this.translateService.instant('item.edit.metadata.notifications.discarded.title');
const content = this.translateService.instant('item.edit.metadata.notifications.discarded.content');
const undoNotification = this.notificationsService.info(title, content, { timeOut: this.discardTimeOut });
this.objectUpdatesService.discardFieldUpdates(this.route, undoNotification);
}
/**
* Request the object updates service to undo discarding all changes to this item
*/
reinstate() {
this.objectUpdatesService.reinstateFieldUpdates(this.route);
}
/**
* Sends all initial values of this item to the object updates service
*/
private initializeOriginalFields() {
this.objectUpdatesService.initialize(this.route, this.item.metadata, this.item.lastModified);
}
/* Prevent unnecessary rerendering so fields don't lose focus **/
protected trackUpdate(index, update: FieldUpdate) {
return update && update.field ? update.field.uuid : undefined;
}
/**
* Requests all current metadata for this item and requests the item service to update the item
* Makes sure the new version of the item is rendered on the page
*/
submit() {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.route, this.item.metadata) as Observable<Metadatum[]>;
metadata$.pipe(
first(),
switchMap((metadata: Metadatum[]) => {
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata });
return this.itemService.update(updatedItem);
}),
getSucceededRemoteData()
).subscribe(
(rd: RemoteData<Item>) => {
this.item = rd.payload;
this.initializeOriginalFields();
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata);
}
)
}
/**
* Checks whether or not there are currently updates for this item
*/
hasChanges(): Observable<boolean> {
return this.objectUpdatesService.hasUpdates(this.route);
}
/**
* Checks whether or not the item is currently reinstatable
*/
isReinstatable(): Observable<boolean> {
return this.objectUpdatesService.isReinstatable(this.route);
}
/**
* Checks if the current item is still in sync with the version in the store
* If it's not, a notification is shown and the changes are removed
*/
private checkLastModified() {
const currentVersion = this.item.lastModified;
this.objectUpdatesService.getLastModified(this.route).pipe(first()).subscribe(
(updateVersion: Date) => {
if (updateVersion.getDate() !== currentVersion.getDate()) {
const title = this.translateService.instant('item.edit.metadata.notifications.outdated.title');
const content = this.translateService.instant('item.edit.metadata.notifications.outdated.content');
this.notificationsService.warning(title, content);
this.initializeOriginalFields();
}
}
);
}
}

View File

@@ -4,9 +4,9 @@ import { RouterModule } from '@angular/router';
import { ItemPageComponent } from './simple/item-page.component';
import { FullItemPageComponent } from './full/full-item-page.component';
import { ItemPageResolver } from './item-page.resolver';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getItemModulePath } from '../app-routing.module';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import {URLCombiner} from '../core/url-combiner/url-combiner';
import {getItemModulePath} from '../app-routing.module';
export function getItemPageRoute(itemId: string) {
return new URLCombiner(getItemModulePath(), itemId).toString();

View File

@@ -21,6 +21,7 @@ import { SearchService } from '../../../search-service/search.service';
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
@Component({
selector: 'ds-search-facet-filter',
@@ -59,7 +60,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
/**
* Emits the result values for this filter found by the current filter query
*/
filterSearchResults: Observable<any[]> = observableOf([]);
filterSearchResults: Observable<InputSuggestion[]> = observableOf([]);
/**
* Emits the active values for this filter
@@ -266,7 +267,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
map(
(rd: RemoteData<PaginatedList<FacetValue>>) => {
return rd.payload.page.map((facet) => {
return { displayValue: this.getDisplayValue(facet, data), value: facet.value }
return {
displayValue: this.getDisplayValue(facet, data),
value: facet.value
}
})
}
))

View File

@@ -1,4 +1,3 @@
import { autoserialize, autoserializeAs } from 'cerialize';
/**

View File

@@ -50,13 +50,13 @@ export class RemoteDataBuildService {
const payload$ =
observableCombineLatest(
href$.pipe(
switchMap((href: string) => this.objectCache.getBySelfLink<NormalizedObject<T>>(href)),
switchMap((href: string) => this.objectCache.getBySelfLink<T>(href)),
startWith(undefined)),
requestEntry$.pipe(
getResourceLinksFromResponse(),
switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getBySelfLink(resourceSelfLinks[0]);
return this.objectCache.getBySelfLink<T>(resourceSelfLinks[0]);
} else {
return observableOf(undefined);
}

View File

@@ -26,7 +26,6 @@ export interface ServerSyncBufferState {
buffer: ServerSyncBufferEntry[];
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState: ServerSyncBufferState = { buffer: [] };
/**

View File

@@ -4,11 +4,13 @@ import { UUIDIndexEffects } from './index/index.effects';
import { RequestEffects } from './data/request.effects';
import { AuthEffects } from './auth/auth.effects';
import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
export const coreEffects = [
RequestEffects,
ObjectCacheEffects,
UUIDIndexEffects,
AuthEffects,
ServerSyncBufferEffects
ServerSyncBufferEffects,
ObjectUpdatesEffects
];

View File

@@ -67,6 +67,7 @@ import { CSSVariableService } from '../shared/sass-helper/sass-helper.service';
import { MenuService } from '../shared/menu/menu.service';
import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service';
import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
import { ObjectUpdatesService } from './data/object-updates/object-updates.service';
const IMPORTS = [
CommonModule,
@@ -134,6 +135,7 @@ const PROVIDERS = [
DSOChangeAnalyzer,
CSSVariableService,
MenuService,
ObjectUpdatesService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,

View File

@@ -1,14 +1,26 @@
import { ActionReducerMap, createFeatureSelector } from '@ngrx/store';
import {
ActionReducerMap,
createFeatureSelector,
createSelector,
MemoizedSelector
} from '@ngrx/store';
import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer';
import { indexReducer, IndexState } from './index/index.reducer';
import { requestReducer, RequestState } from './data/request.reducer';
import { authReducer, AuthState } from './auth/auth.reducer';
import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer';
import {
objectUpdatesReducer,
ObjectUpdatesState
} from './data/object-updates/object-updates.reducer';
import { hasValue } from '../shared/empty.util';
import { AppState } from '../app.reducer';
export interface CoreState {
'cache/object': ObjectCacheState,
'cache/syncbuffer': ServerSyncBufferState,
'cache/object-updates': ObjectUpdatesState
'data/request': RequestState,
'index': IndexState,
'auth': AuthState,
@@ -17,9 +29,10 @@ export interface CoreState {
export const coreReducers: ActionReducerMap<CoreState> = {
'cache/object': objectCacheReducer,
'cache/syncbuffer': serverSyncBufferReducer,
'cache/object-updates': objectUpdatesReducer,
'data/request': requestReducer,
'index': indexReducer,
'auth': authReducer
'auth': authReducer,
};
export const coreSelector = createFeatureSelector<CoreState>('core');
export const coreSelector = createFeatureSelector<CoreState>('core');

View File

@@ -1,73 +1,86 @@
import { type } from '../../../shared/ngrx/type';
import { Action } from '@ngrx/store';
import { Operation } from 'fast-json-patch';
import { Identifiable } from './object-updates.reducer';
import { INotification } from '../../../shared/notifications/models/notification.model';
export const ObjectUpdatesActionTypes = {
ADD: type('dspace/core/object-updates/ADD'),
APPLY: type('dspace/core/object-updates/APPLY'),
DISCARD: type('dspace/core/object-updates/DISCARD'),
REINSTATE: type('dspace/core/object-updates/REINSTATE'),
REMOVE: type('dspace/core/object-updates/REMOVE'),
REMOVE_SINGLE: type('dspace/core/object-updates/REMOVE_SINGLE'),
REPLACE: type('dspace/core/object-updates/REPLACE')
INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'),
SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),
ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
REMOVE: type('dspace/core/cache/object-updates/REMOVE'),
REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'),
};
/* tslint:disable:max-classes-per-file */
export enum FieldChangeType {
UPDATE = 0,
ADD = 1,
REMOVE = 2
}
export class ReplaceObjectUpdatesAction implements Action {
type = ObjectUpdatesActionTypes.REPLACE;
export class InitializeFieldsAction implements Action {
type = ObjectUpdatesActionTypes.INITIALIZE_FIELDS;
payload: {
url: string,
operations: Operation[],
lastModified: number
fields: Identifiable[],
lastModified: Date
};
constructor(
url: string,
operations: Operation[],
lastModified: number
fields: Identifiable[],
lastModified: Date
) {
this.payload = { url, operations, lastModified };
this.payload = { url, fields, lastModified };
}
}
export class AddToObjectUpdatesAction implements Action {
type = ObjectUpdatesActionTypes.ADD;
export class AddFieldUpdateAction implements Action {
type = ObjectUpdatesActionTypes.ADD_FIELD;
payload: {
url: string,
operation: Operation
field: Identifiable,
changeType: FieldChangeType,
};
constructor(
url: string,
operation: Operation) {
this.payload = { url, operation };
field: Identifiable,
changeType: FieldChangeType) {
this.payload = { url, field, changeType };
}
}
export class ApplyObjectUpdatesAction implements Action {
type = ObjectUpdatesActionTypes.APPLY;
export class SetEditableFieldUpdateAction implements Action {
type = ObjectUpdatesActionTypes.SET_EDITABLE_FIELD;
payload: {
url: string
url: string,
uuid: string,
editable: boolean,
};
constructor(
url: string
) {
this.payload.url = url;
url: string,
fieldUUID: string,
editable: boolean) {
this.payload = { url, uuid: fieldUUID, editable };
}
}
export class DiscardObjectUpdatesAction implements Action {
type = ObjectUpdatesActionTypes.DISCARD;
payload: {
url: string
url: string,
notification
};
constructor(
url: string
url: string,
notification: INotification
) {
this.payload.url = url;
this.payload = { url, notification };
}
}
@@ -80,7 +93,7 @@ export class ReinstateObjectUpdatesAction implements Action {
constructor(
url: string
) {
this.payload.url = url;
this.payload = { url };
}
}
@@ -93,35 +106,34 @@ export class RemoveObjectUpdatesAction implements Action {
constructor(
url: string
) {
this.payload.url = url;
this.payload = { url };
}
}
export class RemoveSingleObjectUpdateAction implements Action {
type = ObjectUpdatesActionTypes.REMOVE_SINGLE;
export class RemoveFieldUpdateAction implements Action {
type = ObjectUpdatesActionTypes.REMOVE_FIELD;
payload: {
url: string,
fieldID: string
uuid: string
};
constructor(
url: string,
fieldID: string
uuid: string
) {
this.payload = { url, fieldID };
this.payload = { url, uuid };
}
}
/* tslint:enable:max-classes-per-file */
/**
* A type to encompass all RequestActions
* A type to encompass all ObjectUpdatesActions
*/
export type ObjectUpdatesAction
= AddToObjectUpdatesAction
| ApplyObjectUpdatesAction
= AddFieldUpdateAction
| InitializeFieldsAction
| DiscardObjectUpdatesAction
| ReinstateObjectUpdatesAction
| RemoveObjectUpdatesAction
| RemoveSingleObjectUpdateAction
| ReplaceObjectUpdatesAction;
| RemoveFieldUpdateAction;

View File

@@ -0,0 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs';
import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jasmine-marbles';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { ObjectUpdatesEffects } from './object-updates.effects';
import { RemoveObjectUpdatesAction } from './object-updates.actions';
fdescribe('ObjectUpdatesEffects', () => {
let updatesEffects: ObjectUpdatesEffects;
let actions: Observable<any>;
const testURL = 'www.dspace.org/dspace7';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ObjectUpdatesEffects,
provideMockActions(() => actions),
{
provide: NotificationsService, useClass: {
remove: (notification) => { /* empty */
}
}
},
// other providers
],
});
updatesEffects = TestBed.get(ObjectUpdatesEffects);
});
describe('mapLastActions$', () => {
describe('When any ObjectUpdatesAction is triggered', () => {
const action = new RemoveObjectUpdatesAction(testURL);
it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => {
actions = hot('--a-', { a: action });
const expected = cold('--b-', { b: action });
expect((updatesEffects as any).actionMap[testURL]).toBeObservable(expected);
});
});
});
// describe('removeAfterDiscardOrReinstateOnUndo$', () => {
//
// it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => {
// actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION } });
//
// const expected = cold('--b-', { b: new CollapseMenuAction(MenuID.PUBLIC) });
//
// expect(updatesEffects.routeChange$).toBeObservable(expected);
// });
//
// });
});

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import {
DiscardObjectUpdatesAction,
ObjectUpdatesAction,
ObjectUpdatesActionTypes
} from './object-updates.actions';
import { map } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { hasNoValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
@Injectable()
export class ObjectUpdatesEffects {
private actionMap: {
/* Use Subject instead of BehaviorSubject:
we only want Actions that are fired while we're listening
actions that were previously fired do not matter anymore
*/
[url: string]: Subject<ObjectUpdatesAction>
} = {};
@Effect({ dispatch: false }) mapLastActions$ = this.actions$
.pipe(
ofType(...Object.values(ObjectUpdatesActionTypes)),
map((action: DiscardObjectUpdatesAction) => {
const url: string = action.payload.url;
if (hasNoValue(this.actionMap[url])) {
this.actionMap[url] = new Subject<ObjectUpdatesAction>();
}
this.actionMap[url].next(action);
}
)
);
// @Effect() removeAfterDiscardOrReinstateOnUndo$ = this.actions$
// .pipe(
// ofType(ObjectUpdatesActionTypes.DISCARD),
// switchMap((action: DiscardObjectUpdatesAction) => {
// const url: string = action.payload.url;
// const notification: INotification = action.payload.notification;
// const timeOut = notification.options.timeOut;
// return observableRace(
// // Either wait for the delay and perform a remove action
// observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)),
// // Or wait for a reinstate action and perform no action
// this.actionMap[url].pipe(
// // filter((updateAction: ObjectUpdatesAction) => updateAction.type === ObjectUpdatesActionTypes.REINSTATE),
// tap(() => this.notificationsService.remove(notification)),
// map(() => {
// return { type: 'NO_ACTION' }
// }
// )
// )
// )
// }
// )
// );
constructor(private actions$: Actions, private notificationsService: NotificationsService) {
}
}

View File

@@ -1,45 +1,66 @@
import {
AddToObjectUpdatesAction,
ApplyObjectUpdatesAction,
AddFieldUpdateAction,
DiscardObjectUpdatesAction,
FieldChangeType,
InitializeFieldsAction,
ObjectUpdatesAction,
ObjectUpdatesActionTypes,
ReinstateObjectUpdatesAction,
RemoveObjectUpdatesAction,
RemoveSingleObjectUpdateAction, ReplaceObjectUpdatesAction
RemoveFieldUpdateAction,
RemoveObjectUpdatesAction, SetEditableFieldUpdateAction
} from './object-updates.actions';
import { Operation } from 'fast-json-patch';
import { hasValue } from '../../../shared/empty.util';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
export const OBJECT_UPDATES_TRASH_PATH = '/trash';
export interface FieldState {
editable: boolean,
isNew: boolean
}
export interface FieldStates {
[uuid: string]: FieldState;
}
export interface Identifiable {
uuid: string
}
export interface FieldUpdate {
field: Identifiable,
changeType: FieldChangeType
}
export interface FieldUpdates {
[uuid: string]: FieldUpdate;
}
export interface ObjectUpdatesEntry {
updates: Operation[];
lastServerUpdate: number;
lastModified: number;
discarded: boolean;
fieldStates: FieldStates;
fieldUpdates: FieldUpdates
lastModified: Date;
// lastUpdate: Date;
}
export interface ObjectUpdatesState {
[url: string]: ObjectUpdatesEntry;
}
const initialFieldState = { editable: false, isNew: false };
const initialNewFieldState = { editable: true, isNew: true };
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
export function objectUpdatesReducer(state = initialState, action: ObjectUpdatesAction): ObjectUpdatesState {
let newState = state;
switch (action.type) {
case ObjectUpdatesActionTypes.REPLACE: {
newState = replaceObjectUpdates(state, action as ReplaceObjectUpdatesAction);
case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: {
newState = initializeFieldsUpdate(state, action as InitializeFieldsAction);
break;
}
case ObjectUpdatesActionTypes.ADD: {
newState = addToObjectUpdates(state, action as AddToObjectUpdatesAction);
break;
}
case ObjectUpdatesActionTypes.APPLY: {
/* For now do nothing, handle in effect */
// return applyObjectUpdates(state, action as ApplyObjectUpdatesAction);
newState = state;
case ObjectUpdatesActionTypes.ADD_FIELD: {
newState = addFieldUpdate(state, action as AddFieldUpdateAction);
break;
}
case ObjectUpdatesActionTypes.DISCARD: {
@@ -54,86 +75,162 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
newState = removeObjectUpdates(state, action as RemoveObjectUpdatesAction);
break;
}
case ObjectUpdatesActionTypes.REMOVE_SINGLE: {
newState = removeSingleObjectUpdates(state, action as RemoveSingleObjectUpdateAction);
case ObjectUpdatesActionTypes.REMOVE_FIELD: {
newState = removeFieldUpdate(state, action as RemoveFieldUpdateAction);
break;
}
case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: {
// return directly, no need to change the lastModified date
return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction);
}
default: {
return state;
}
}
return setLastModified(newState, action.payload.url);
}
function replaceObjectUpdates(state: any, action: ReplaceObjectUpdatesAction) {
const key: string = action.payload.url;
const operations: Operation[] = action.payload.operations;
const newUpdateEntry = Object.assign({}, state[key] || {}, { updates: operations });
return Object.assign({}, state, { [key]: newUpdateEntry });
}
function addToObjectUpdates(state: any, action: AddToObjectUpdatesAction) {
const key: string = action.payload.url;
const operation: Operation = action.payload.operation;
const keyState = state[key] || {
updates: {},
lastServerUpdate: 0,
discarded: false
};
const objectUpdates: Operation[] = keyState.updates || [];
const newUpdates = [...objectUpdates, operation];
const newKeyState = Object.assign({}, state[key], { updates: newUpdates });
return Object.assign({}, state, newKeyState);
}
function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
const key: string = action.payload.url;
const keyState = state[key];
if (hasValue(keyState)) {
const newKeyState = Object.assign({}, keyState, { discarded: true });
return Object.assign({}, state, newKeyState);
}
return state;
}
function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction) {
const key: string = action.payload.url;
const keyState = state[key];
if (hasValue(keyState)) {
const newKeyState = Object.assign({}, keyState, { discarded: false });
return Object.assign({}, state, newKeyState);
}
return state;
}
function removeObjectUpdates(state: any, action: RemoveObjectUpdatesAction) {
const key: string = action.payload.url;
return removeObjectUpdatesByURL(state, key);
}
function removeObjectUpdatesByURL(state: any, url: string) {
const keyState = state[url];
const newState = Object.assign({}, state);
if (hasValue(keyState)) {
delete newState[url];
}
// return setUpdated(newState, action.payload.url);
return newState;
}
function removeSingleObjectUpdates(state: any, action: RemoveSingleObjectUpdateAction) {
const key: string = action.payload.url;
let newKeyState = state[key];
if (hasValue(newKeyState)) {
const newUpdates: Operation[] = Object.assign({}, newKeyState.updates);
if (hasValue(newUpdates[action.payload.fieldID])) {
delete newUpdates[action.payload.fieldID];
}
newKeyState = Object.assign({}, state[key], { updates: newUpdates });
}
return Object.assign({}, state, newKeyState);
function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
const url: string = action.payload.url;
const fields: Identifiable[] = action.payload.fields;
const lastModifiedServer: Date = action.payload.lastModified;
const fieldStates = createInitialFieldStates(fields);
const newPageState = Object.assign(
{},
state[url],
{ fieldStates: fieldStates },
{ fieldUpdates: {} },
{ lastServerUpdate: lastModifiedServer }
);
return Object.assign({}, state, { [url]: newPageState });
}
function setLastModified(state: any, url: string) {
const newKeyState = Object.assign({}, state[url] || {}, { lastModified: Date.now() });
return Object.assign({}, state, newKeyState);
function addFieldUpdate(state: any, action: AddFieldUpdateAction) {
const url: string = action.payload.url;
const field: Identifiable = action.payload.field;
const changeType: FieldChangeType = action.payload.changeType;
const pageState: ObjectUpdatesEntry = state[url] || {};
let states = pageState.fieldStates;
if (changeType === FieldChangeType.ADD) {
states = Object.assign({}, { [field.uuid]: initialNewFieldState }, pageState.fieldStates)
}
let fieldUpdate: any = pageState.fieldUpdates[field.uuid] || {};
const newChangeType = determineChangeType(fieldUpdate.changeType, changeType);
fieldUpdate = Object.assign({}, { field, changeType: newChangeType });
const fieldUpdates = Object.assign({}, pageState.fieldUpdates, { [field.uuid]: fieldUpdate });
const newPageState = Object.assign({}, pageState,
{ fieldStates: states },
{ fieldUpdates: fieldUpdates });
return Object.assign({}, state, { [url]: newPageState });
}
function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
const url: string = action.payload.url;
const pageState: ObjectUpdatesEntry = state[url];
const newFieldStates = {};
Object.keys(pageState.fieldStates).forEach((uuid: string) => {
const fieldState: FieldState = pageState.fieldStates[uuid];
if (!fieldState.isNew) {
/* After discarding we don't want the reset fields to stay editable */
newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false });
}
});
const discardedPageState = Object.assign({}, pageState, {
fieldUpdates: {},
fieldStates: newFieldStates
});
return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState });
}
function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction) {
const url: string = action.payload.url;
const trashState = state[url + OBJECT_UPDATES_TRASH_PATH];
const newState = Object.assign({}, state, { [url]: trashState });
delete newState[url + OBJECT_UPDATES_TRASH_PATH];
return newState;
}
function removeObjectUpdates(state: any, action: RemoveObjectUpdatesAction) {
const url: string = action.payload.url;
return removeObjectUpdatesByURL(state, url);
}
function removeObjectUpdatesByURL(state: any, url: string) {
const newState = Object.assign({}, state);
delete newState[url + OBJECT_UPDATES_TRASH_PATH];
return newState;
}
function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) {
const url: string = action.payload.url;
const uuid: string = action.payload.uuid;
let newPageState: ObjectUpdatesEntry = state[url];
if (hasValue(newPageState)) {
const newUpdates: FieldUpdates = Object.assign({}, newPageState.fieldUpdates);
if (hasValue(newUpdates[uuid])) {
delete newUpdates[uuid];
}
const newFieldStates: FieldStates = Object.assign({}, newPageState.fieldStates);
if (hasValue(newFieldStates[uuid])) {
/* When resetting, make field not editable */
if (newFieldStates[uuid].isNew) {
/* If this field was added, just throw it away */
delete newFieldStates[uuid];
} else {
newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false });
}
}
newPageState = Object.assign({}, state[url], {
fieldUpdates: newUpdates,
fieldStates: newFieldStates
});
}
return Object.assign({}, state, { [url]: newPageState });
}
function setUpdated(state: any, url: string) {
const newPageState = Object.assign({}, state[url] || {}, { lastUpdated: Date.now() });
return Object.assign({}, state, { [url]: newPageState });
}
function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType): FieldChangeType {
if (hasNoValue(newType)) {
return oldType;
}
if (hasNoValue(oldType)) {
return newType;
}
return oldType.valueOf() > newType.valueOf() ? oldType : newType;
}
function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction) {
const url: string = action.payload.url;
const uuid: string = action.payload.uuid;
const editable: boolean = action.payload.editable;
const pageState: ObjectUpdatesEntry = state[url];
const fieldState = pageState.fieldStates[uuid];
const newFieldState = Object.assign({}, fieldState, { editable });
const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState });
const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates });
return Object.assign({}, state, { [url]: newPageState });
}
function createInitialFieldStates(fields: Identifiable[]) {
const uuids = fields.map((field: Identifiable) => field.uuid);
const fieldStates = {};
uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState);
return fieldStates;
}

View File

@@ -0,0 +1,134 @@
import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { coreSelector, CoreState } from '../../core.reducers';
import {
FieldUpdates,
Identifiable, OBJECT_UPDATES_TRASH_PATH,
ObjectUpdatesEntry,
ObjectUpdatesState
} from './object-updates.reducer';
import { Observable } from 'rxjs';
import {
AddFieldUpdateAction,
DiscardObjectUpdatesAction,
FieldChangeType,
InitializeFieldsAction,
ReinstateObjectUpdatesAction,
RemoveFieldUpdateAction,
SetEditableFieldUpdateAction
} from './object-updates.actions';
import { filter, map } from 'rxjs/operators';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { INotification } from '../../../shared/notifications/models/notification.model';
function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> {
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
}
function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector<CoreState, ObjectUpdatesEntry> {
return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]);
}
@Injectable()
export class ObjectUpdatesService {
constructor(private store: Store<CoreState>) {
}
initialize(url, fields: Identifiable[], lastModified: Date): void {
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified));
}
private saveFieldUpdate(url: string, field: Identifiable, changeType: FieldChangeType) {
this.store.dispatch(new AddFieldUpdateAction(url, field, changeType))
}
private getObjectEntry(url: string): Observable<ObjectUpdatesEntry> {
return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url)));
}
getFieldUpdates(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> {
const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(map((objectEntry) => {
const fieldUpdates: FieldUpdates = {};
Object.keys(objectEntry.fieldStates).forEach((uuid) => {
let fieldUpdate = objectEntry.fieldUpdates[uuid];
if (isEmpty(fieldUpdate)) {
const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid);
fieldUpdate = { field: identifiable, changeType: undefined };
}
fieldUpdates[uuid] = fieldUpdate;
});
return fieldUpdates;
}))
}
isEditable(url: string, uuid: string): Observable<boolean> {
const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(
filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])),
map((objectEntry) => objectEntry.fieldStates[uuid].editable
)
)
}
saveAddFieldUpdate(url: string, field: Identifiable) {
this.saveFieldUpdate(url, field, FieldChangeType.ADD);
}
saveRemoveFieldUpdate(url: string, field: Identifiable) {
this.saveFieldUpdate(url, field, FieldChangeType.REMOVE);
}
saveChangeFieldUpdate(url: string, field: Identifiable) {
this.saveFieldUpdate(url, field, FieldChangeType.UPDATE);
}
setEditableFieldUpdate(url: string, uuid: string, editable: boolean) {
this.store.dispatch(new SetEditableFieldUpdateAction(url, uuid, editable));
}
discardFieldUpdates(url: string, undoNotification: INotification) {
this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification));
}
reinstateFieldUpdates(url: string) {
this.store.dispatch(new ReinstateObjectUpdatesAction(url));
}
removeSingleFieldUpdate(url: string, uuid) {
this.store.dispatch(new RemoveFieldUpdateAction(url, uuid));
}
getUpdatedFields(url: string, initialFields: Identifiable[]): Observable<Identifiable[]> {
const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(map((objectEntry) => {
const fields: Identifiable[] = [];
Object.keys(objectEntry.fieldStates).forEach((uuid) => {
const fieldUpdate = objectEntry.fieldUpdates[uuid];
if (hasNoValue(fieldUpdate) || fieldUpdate.changeType !== FieldChangeType.REMOVE) {
let field;
if (isNotEmpty(fieldUpdate)) {
field = fieldUpdate.field;
} else {
field = initialFields.find((object: Identifiable) => object.uuid === uuid);
}
fields.push(field);
}
});
return fields;
}))
}
hasUpdates(url: string): Observable<boolean> {
return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates)));
}
isReinstatable(route: string): Observable<boolean> {
return this.hasUpdates(route + OBJECT_UPDATES_TRASH_PATH)
}
getLastModified(url: string): Observable<Date> {
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
}
}

View File

@@ -1,6 +1,7 @@
import { MetadataSchema } from './metadataschema.model';
import { autoserialize } from 'cerialize';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
import { isNotEmpty } from '../../shared/empty.util';
export class MetadataField implements ListableObject {
@autoserialize
@@ -20,4 +21,12 @@ export class MetadataField implements ListableObject {
@autoserialize
schema: MetadataSchema;
toString(): string {
let key = this.schema.prefix + '.' + this.element;
if (isNotEmpty(this.qualifier)) {
key += '.' + this.qualifier;
}
return key;
}
}

View File

@@ -33,13 +33,19 @@ import { URLCombiner } from '../url-combiner/url-combiner';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service';
import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model';
import { configureRequest, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators';
import {
configureRequest,
getResponseFromEntry,
getSucceededRemoteData
} from '../shared/operators';
import { createSelector, select, Store } from '@ngrx/store';
import { AppState } from '../../app.reducer';
import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers';
import {
MetadataRegistryCancelFieldAction,
MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction, MetadataRegistryDeselectAllSchemaAction,
MetadataRegistryCancelSchemaAction,
MetadataRegistryDeselectAllFieldAction,
MetadataRegistryDeselectAllSchemaAction,
MetadataRegistryDeselectFieldAction,
MetadataRegistryDeselectSchemaAction,
MetadataRegistryEditFieldAction,
@@ -168,7 +174,7 @@ export class RegistryService {
public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataField>>> {
if (hasNoValue(pagination)) {
pagination = { currentPage: 1, pageSize: Number.MAX_VALUE } as any;
pagination = { currentPage: 1, pageSize: 10000 } as any;
}
const requestObs = this.getMetadataFieldsRequestObs(pagination);
@@ -533,4 +539,19 @@ export class RegistryService {
}
});
}
queryMetadataFields(query: string): Observable<RemoteData<PaginatedList<MetadataField>>> {
/**
* This should come directly from the server in the future
*/
return this.getAllMetadataFields().pipe(
map((rd: RemoteData<PaginatedList<MetadataField>>) => {
const filteredFields: MetadataField[] = rd.payload.page.filter(
(field: MetadataField) => field.toString().indexOf(query) >= 0
);
const page: PaginatedList<MetadataField> = new PaginatedList<MetadataField>(new PageInfo(), filteredFields)
return Object.assign({}, rd, { payload: page });
})
);
}
}

View File

@@ -1,7 +1,9 @@
import { autoserialize } from 'cerialize';
import * as uuidv4 from 'uuid/v4';
export class Metadatum {
uuid: string = uuidv4();
/**
* The metadata field of this Metadatum
*/

View File

@@ -1,21 +1,21 @@
<form #form="ngForm" (ngSubmit)="submitSuggestion.emit(ngModel)"
<form #form="ngForm" (ngSubmit)="onSubmit(value)"
[action]="action" (keydown)="onKeydown($event)"
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close()">
<input #inputField type="text" [(ngModel)]="ngModel" [name]="name"
<input #inputField type="text" [(ngModel)]="value" [name]="name"
class="form-control suggestion_input"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<ul class="list-unstyled">
<li *ngFor="let suggestionOption of suggestions">
<div>
<div *ngFor="let suggestionOption of suggestions">
<a href="#" class="d-block dropdown-item" (click)="onClickSuggestion(suggestionOption.value)" #suggestion>
<span [innerHTML]="suggestionOption.displayValue"></span>
</a>
</li>
</ul>
</div>
</div>
</div>
</form>

View File

@@ -1,8 +1,11 @@
@import "../../../styles/_variables.scss";
.autocomplete {
width: 100%;
.dropdown-item {
white-space: normal;
word-break: break-word;
padding: $input-padding-y $input-padding-x;
&:focus {
outline: none;
}

View File

@@ -1,6 +1,6 @@
import {
Component,
ElementRef, EventEmitter,
ElementRef, EventEmitter, forwardRef,
Input,
Output,
QueryList, SimpleChanges,
@@ -9,21 +9,30 @@ import {
} from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { hasValue, isNotEmpty } from '../empty.util';
import { InputSuggestion } from './input-suggestions.model';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'ds-input-suggestions',
styleUrls: ['./input-suggestions.component.scss'],
templateUrl: './input-suggestions.component.html'
templateUrl: './input-suggestions.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => InputSuggestionsComponent),
multi: true
}
]
})
/**
* Component representing a form with a autocomplete functionality
*/
export class InputSuggestionsComponent {
export class InputSuggestionsComponent implements ControlValueAccessor {
/**
* The suggestions that should be shown
*/
@Input() suggestions: any[] = [];
@Input() suggestions: InputSuggestion[] = [];
/**
* The time waited to detect if any other input will follow before requesting the suggestions
@@ -45,16 +54,6 @@ export class InputSuggestionsComponent {
*/
@Input() name;
/**
* Value of the input field
*/
@Input() ngModel;
/**
* Output for when the input field's value changes
*/
@Output() ngModelChange = new EventEmitter();
/**
* Output for when the form is submitted
*/
@@ -94,6 +93,15 @@ export class InputSuggestionsComponent {
*/
@ViewChildren('suggestion') resultViews: QueryList<ElementRef>;
/**
* Value of the input field
*/
_value: string;
propagateChange = (_: any) => {
/* Empty implementation */
};
/**
* When any of the inputs change, check if we should still show the suggestions
*/
@@ -170,6 +178,7 @@ export class InputSuggestionsComponent {
* Make sure that if a suggestion is clicked, the suggestions dropdown closes, does not reopen and the focus moves to the input field
*/
onClickSuggestion(data) {
this.value = data;
this.clickSuggestion.emit(data);
this.close();
this.blockReopen = true;
@@ -188,4 +197,31 @@ export class InputSuggestionsComponent {
this.blockReopen = false;
}
onSubmit(data) {
this.value = data;
this.submitSuggestion.emit(data);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
}
writeValue(value: any): void {
this.value = value;
}
get value() {
return this._value;
}
set value(val) {
this._value = val;
this.propagateChange(this._value);
}
}

View File

@@ -0,0 +1,4 @@
export interface InputSuggestion {
displayValue: string,
value: string
}

View File

@@ -27,27 +27,30 @@ export class NotificationsService {
success(title: any = observableOf(''),
content: any = observableOf(''),
options: NotificationOptions = this.getDefaultOptions(),
options: Partial<NotificationOptions> = {},
html: boolean = false): INotification {
const notification = new Notification(uniqueId(), NotificationType.Success, title, content, options, html);
const notificationOptions = { ...this.getDefaultOptions(), ...options };
const notification = new Notification(uniqueId(), NotificationType.Success, title, content, notificationOptions, html);
this.add(notification);
return notification;
}
error(title: any = observableOf(''),
content: any = observableOf(''),
options: NotificationOptions = this.getDefaultOptions(),
options: Partial<NotificationOptions> = {},
html: boolean = false): INotification {
const notification = new Notification(uniqueId(), NotificationType.Error, title, content, options, html);
const notificationOptions = { ...this.getDefaultOptions(), ...options };
const notification = new Notification(uniqueId(), NotificationType.Error, title, content, notificationOptions, html);
this.add(notification);
return notification;
}
info(title: any = observableOf(''),
content: any = observableOf(''),
options: NotificationOptions = this.getDefaultOptions(),
options: Partial<NotificationOptions> = {},
html: boolean = false): INotification {
const notification = new Notification(uniqueId(), NotificationType.Info, title, content, options, html);
const notificationOptions = { ...this.getDefaultOptions(), ...options };
const notification = new Notification(uniqueId(), NotificationType.Info, title, content, notificationOptions, html);
this.add(notification);
return notification;
}
@@ -56,7 +59,8 @@ export class NotificationsService {
content: any = observableOf(''),
options: NotificationOptions = this.getDefaultOptions(),
html: boolean = false): INotification {
const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, options, html);
const notificationOptions = { ...this.getDefaultOptions(), ...options };
const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, notificationOptions, html);
this.add(notification);
return notification;
}

View File

@@ -91,6 +91,7 @@ import { CreateComColPageComponent } from './comcol-forms/create-comcol-page/cre
import { EditComColPageComponent } from './comcol-forms/edit-comcol-page/edit-comcol-page.component';
import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component';
import { LangSwitchComponent } from './lang-switch/lang-switch.component';
import { ObjectValuesPipe } from './utils/object-values-pipe';
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -124,6 +125,7 @@ const PIPES = [
EmphasizePipe,
CapitalizePipe,
ObjectKeysPipe,
ObjectValuesPipe,
ConsolePipe
];

View File

@@ -0,0 +1,18 @@
import { PipeTransform, Pipe } from '@angular/core';
@Pipe({name: 'dsObjectValues'})
/**
* Pipe for parsing all values of an object to an array of values
*/
export class ObjectValuesPipe implements PipeTransform {
/**
* @param value An object
* @returns {any} Array with all values of the input object
*/
transform(value, args:string[]): any {
const values = [];
Object.values(value).forEach((v) => values.push(v));
return values;
}
}

View File

@@ -24,7 +24,7 @@ $gray-100: lighten($gray-base, 93.5%) !default; // #eee
$blue: #2B4E72 !default;
$green: #94BA65 !default;
$cyan: #2790B0 !default;
$yellow: #EBBB54 !default;
$yellow: #ec9433 !default;
$red: #CF4444 !default;
$dark: darken($blue, 17%) !default;
@@ -56,3 +56,4 @@ $grid-breakpoints: (
xl: (1200px - $collapsed-sidebar-width)
) !default;
$yiq-contrasted-threshold: 165 !default;