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', code: 'nl',
label: 'Nederlands', label: 'Nederlands',
active: false, active: false,
}] }],
item: {
}
}; };

View File

@@ -223,7 +223,26 @@
"error": "An error occured while deleting the item" "error": "An error occured while deleting the item"
}, },
"metadata": { "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> </ng-template>
</ngb-tab> </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> <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> </ng-template>
</ngb-tab> </ngb-tab>
<ngb-tab title="{{'item.edit.tabs.view.head' | translate}}"> <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 {ItemPublicComponent} from './item-public/item-public.component';
import {ItemDeleteComponent} from './item-delete/item-delete.component'; import {ItemDeleteComponent} from './item-delete/item-delete.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.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 * 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, ItemDeleteComponent,
ItemStatusComponent, ItemStatusComponent,
ItemMetadataComponent, ItemMetadataComponent,
EditInPlaceComponent EditInPlaceFieldComponent
] ]
}) })
export class EditItemPageModule { export class EditItemPageModule {

View File

@@ -1,42 +1,47 @@
<td class="col-3"> <div [ngClass]="{
<!--<div *ngIf="!editable">--> 'table-warning': fieldUpdate.changeType === 0,
<span>{{metadata.key}}</span> 'table-danger': fieldUpdate.changeType === 2,
<!--</div>--> 'table-success': fieldUpdate.changeType === 1
<!--<div *ngIf="editable" class="field-container">--> }" class="d-flex">
<!--<ds-input-suggestions [suggestions]="(filterSearchResults | async)"--> <!--{{metadata?.uuid}}-->
<!--[action]="getCurrentUrl()"--> <td class="col-3">
<!--[name]="filterConfig.paramName"--> <div *ngIf="!(editable | async)">
<!--[(ngModel)]="metadata.key"--> <span>{{metadata?.key}}</span>
<!--(submitSuggestion)="updateField($event)"--> </div>
<!--(clickSuggestion)="updateField($event)"--> <div *ngIf="(editable | async)" class="field-container">
<!--(findSuggestions)="findSuggestions($event)"--> <ds-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
<!--ngDefaultControl--> [(ngModel)]="metadata.key"
<!--&gt;</ds-input-suggestions>--> (submitSuggestion)="update()"
<!--</div>--> (clickSuggestion)="update()"
</td> (findSuggestions)="findMetadataFieldSuggestions($event)"
<td class="col-7"> ngDefaultControl
<div *ngIf="!editable"> ></ds-input-suggestions>
<span>{{metadata.value}}</span> </div>
</div> </td>
<div *ngIf="editable" class="field-container"> <td class="col-7">
<textarea type="textarea" [ngModel]="metadata.value" [dsDebounce] (onDebounce)="update()"></textarea> <div *ngIf="!(editable | async)">
</div> <span>{{metadata?.value}}</span>
</td> </div>
<td class="col-1"> <div *ngIf="(editable | async)" class="field-container">
<div *ngIf="!editable"> <textarea class="form-control" type="textarea" [(ngModel)]="metadata.value" [dsDebounce]
<span>{{metadata.language}}</span> (onDebounce)="update()"></textarea>
</div> </div>
<div *ngIf="editable" class="field-container"> </td>
<input type="text" [ngModel]="metadata.language" [dsDebounce] (onDebounce)="update()"/> <td class="col-1 text-center">
</div> <div *ngIf="!(editable | async)">
</td> <span>{{metadata?.language}}</span>
<td class="col-1"> </div>
<div *ngIf="!editable"> <div *ngIf="(editable | async)" class="field-container">
<i class="fas fa-edit fa-fw" (click)="editable = !editable"></i> <input class="form-control" type="text" [(ngModel)]="metadata.language" [dsDebounce]
<i class="fas fa-trash fa-fw" (click)="remove()"></i> (onDebounce)="update()"/>
</div> </div>
<div *ngIf="editable"> </td>
<i class="fas fa-times fa-fw" (click)="editable = !editable"></i> <td class="col-1 text-center">
<i class="fas fa-trash fa-fw" (click)="remove()"></i> <div>
</div> <i *ngIf="canSetEditable() | async" class="fas fa-edit fa-fw text-primary" (click)="setEditable(true)"></i>
</td> <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 { isNotEmpty } from '../../../../shared/empty.util';
import { Metadatum } from '../../../../core/shared/metadatum.model'; import { Metadatum } from '../../../../core/shared/metadatum.model';
import { RegistryService } from '../../../../core/registry/registry.service'; 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({ @Component({
selector: 'ds-edit-in-place-field.', selector: 'ds-edit-in-place-field',
styleUrls: ['./edit-in-place-field.component.scss'], styleUrls: ['./edit-in-place-field.component.scss'],
templateUrl: './edit-in-place-field.component.html', 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; @Input() fieldUpdate: FieldUpdate;
@Output() mdUpdate: EventEmitter<any> = new EventEmitter(); /**
@Output() mdRemove: EventEmitter<any> = new EventEmitter(); * The current route of this page
editable = false; */
@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( constructor(
private metadataFieldService: RegistryService, 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); return isNotEmpty(value);
} }
update() {
this.mdUpdate.emit();
}
remove() {
this.mdRemove.emit()
}
} }

View File

@@ -1,12 +1,55 @@
<div class="container"> <div class="container">
<div class="item-metadata"> <div class="item-metadata">
<button class="btn btn-primary w-100 my-2" (click)="update()">{{"item.edit.metadata.add-button" | translate}}</button> <div class="button-row d-flex justify-content-between">
<table class="table table-responsive table-striped"> <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> <tbody>
<tr *ngFor="let metadatum of item.metadata; let i=index"> <tr class="d-flex">
<ds-edit-in-place-field [metadata]="metadatum" class="d-flex" (mdUpdate)="update()" (mdRemove)="update()"></ds-edit-in-place-field> <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> </tr>
</tbody> </tbody>
</table> </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>
</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 { Item } from '../../../core/shared/item.model';
import { ItemDataService } from '../../../core/data/item-data.service'; 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 { 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({ @Component({
selector: 'ds-item-metadata', selector: 'ds-item-metadata',
templateUrl: './item-metadata.component.html', 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; @Input() item: Item;
updateItem: Item; /**
constructor(private itemService: ItemDataService, private dsoChanges: DSOChangeAnalyzer<Item>) { * The current values and updates for all this item's metadata fields
this.updateItem = Object.assign({}, this.item); */
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() { ngOnInit(): void {
this.dsoChanges.diff(this.item, this.updateItem); 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() { /**
// * 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
// */
// discard() { add(metadata: Metadatum = new Metadatum()) {
// this.objectUpdatesService.saveAddFieldUpdate(this.route, metadata);
// } }
//
// undo() { /**
// * 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 { ItemPageComponent } from './simple/item-page.component';
import { FullItemPageComponent } from './full/full-item-page.component'; import { FullItemPageComponent } from './full/full-item-page.component';
import { ItemPageResolver } from './item-page.resolver'; 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 { 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) { export function getItemPageRoute(itemId: string) {
return new URLCombiner(getItemModulePath(), itemId).toString(); 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 { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { getSucceededRemoteData } from '../../../../core/shared/operators'; import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
@Component({ @Component({
selector: 'ds-search-facet-filter', 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 * 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 * Emits the active values for this filter
@@ -266,7 +267,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
map( map(
(rd: RemoteData<PaginatedList<FacetValue>>) => { (rd: RemoteData<PaginatedList<FacetValue>>) => {
return rd.payload.page.map((facet) => { 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'; import { autoserialize, autoserializeAs } from 'cerialize';
/** /**

View File

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

View File

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

View File

@@ -4,11 +4,13 @@ import { UUIDIndexEffects } from './index/index.effects';
import { RequestEffects } from './data/request.effects'; import { RequestEffects } from './data/request.effects';
import { AuthEffects } from './auth/auth.effects'; import { AuthEffects } from './auth/auth.effects';
import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
export const coreEffects = [ export const coreEffects = [
RequestEffects, RequestEffects,
ObjectCacheEffects, ObjectCacheEffects,
UUIDIndexEffects, UUIDIndexEffects,
AuthEffects, 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 { MenuService } from '../shared/menu/menu.service';
import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service';
import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
import { ObjectUpdatesService } from './data/object-updates/object-updates.service';
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -134,6 +135,7 @@ const PROVIDERS = [
DSOChangeAnalyzer, DSOChangeAnalyzer,
CSSVariableService, CSSVariableService,
MenuService, MenuService,
ObjectUpdatesService,
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor
{ {
provide: HTTP_INTERCEPTORS, 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 { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer';
import { indexReducer, IndexState } from './index/index.reducer'; import { indexReducer, IndexState } from './index/index.reducer';
import { requestReducer, RequestState } from './data/request.reducer'; import { requestReducer, RequestState } from './data/request.reducer';
import { authReducer, AuthState } from './auth/auth.reducer'; import { authReducer, AuthState } from './auth/auth.reducer';
import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.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 { export interface CoreState {
'cache/object': ObjectCacheState, 'cache/object': ObjectCacheState,
'cache/syncbuffer': ServerSyncBufferState, 'cache/syncbuffer': ServerSyncBufferState,
'cache/object-updates': ObjectUpdatesState
'data/request': RequestState, 'data/request': RequestState,
'index': IndexState, 'index': IndexState,
'auth': AuthState, 'auth': AuthState,
@@ -17,9 +29,10 @@ export interface CoreState {
export const coreReducers: ActionReducerMap<CoreState> = { export const coreReducers: ActionReducerMap<CoreState> = {
'cache/object': objectCacheReducer, 'cache/object': objectCacheReducer,
'cache/syncbuffer': serverSyncBufferReducer, 'cache/syncbuffer': serverSyncBufferReducer,
'cache/object-updates': objectUpdatesReducer,
'data/request': requestReducer, 'data/request': requestReducer,
'index': indexReducer, '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 { type } from '../../../shared/ngrx/type';
import { Action } from '@ngrx/store'; 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 = { export const ObjectUpdatesActionTypes = {
ADD: type('dspace/core/object-updates/ADD'), INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'),
APPLY: type('dspace/core/object-updates/APPLY'), SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),
DISCARD: type('dspace/core/object-updates/DISCARD'), ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
REINSTATE: type('dspace/core/object-updates/REINSTATE'), DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
REMOVE: type('dspace/core/object-updates/REMOVE'), REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
REMOVE_SINGLE: type('dspace/core/object-updates/REMOVE_SINGLE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'),
REPLACE: type('dspace/core/object-updates/REPLACE') REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'),
}; };
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
export enum FieldChangeType {
UPDATE = 0,
ADD = 1,
REMOVE = 2
}
export class ReplaceObjectUpdatesAction implements Action { export class InitializeFieldsAction implements Action {
type = ObjectUpdatesActionTypes.REPLACE; type = ObjectUpdatesActionTypes.INITIALIZE_FIELDS;
payload: { payload: {
url: string, url: string,
operations: Operation[], fields: Identifiable[],
lastModified: number lastModified: Date
}; };
constructor( constructor(
url: string, url: string,
operations: Operation[], fields: Identifiable[],
lastModified: number lastModified: Date
) { ) {
this.payload = { url, operations, lastModified }; this.payload = { url, fields, lastModified };
} }
} }
export class AddToObjectUpdatesAction implements Action { export class AddFieldUpdateAction implements Action {
type = ObjectUpdatesActionTypes.ADD; type = ObjectUpdatesActionTypes.ADD_FIELD;
payload: { payload: {
url: string, url: string,
operation: Operation field: Identifiable,
changeType: FieldChangeType,
}; };
constructor( constructor(
url: string, url: string,
operation: Operation) { field: Identifiable,
this.payload = { url, operation }; changeType: FieldChangeType) {
this.payload = { url, field, changeType };
} }
} }
export class ApplyObjectUpdatesAction implements Action { export class SetEditableFieldUpdateAction implements Action {
type = ObjectUpdatesActionTypes.APPLY; type = ObjectUpdatesActionTypes.SET_EDITABLE_FIELD;
payload: { payload: {
url: string url: string,
uuid: string,
editable: boolean,
}; };
constructor( constructor(
url: string url: string,
) { fieldUUID: string,
this.payload.url = url; editable: boolean) {
this.payload = { url, uuid: fieldUUID, editable };
} }
} }
export class DiscardObjectUpdatesAction implements Action { export class DiscardObjectUpdatesAction implements Action {
type = ObjectUpdatesActionTypes.DISCARD; type = ObjectUpdatesActionTypes.DISCARD;
payload: { payload: {
url: string url: string,
notification
}; };
constructor( 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( constructor(
url: string url: string
) { ) {
this.payload.url = url; this.payload = { url };
} }
} }
@@ -93,35 +106,34 @@ export class RemoveObjectUpdatesAction implements Action {
constructor( constructor(
url: string url: string
) { ) {
this.payload.url = url; this.payload = { url };
} }
} }
export class RemoveSingleObjectUpdateAction implements Action { export class RemoveFieldUpdateAction implements Action {
type = ObjectUpdatesActionTypes.REMOVE_SINGLE; type = ObjectUpdatesActionTypes.REMOVE_FIELD;
payload: { payload: {
url: string, url: string,
fieldID: string uuid: string
}; };
constructor( constructor(
url: string, url: string,
fieldID: string uuid: string
) { ) {
this.payload = { url, fieldID }; this.payload = { url, uuid };
} }
} }
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
/** /**
* A type to encompass all RequestActions * A type to encompass all ObjectUpdatesActions
*/ */
export type ObjectUpdatesAction export type ObjectUpdatesAction
= AddToObjectUpdatesAction = AddFieldUpdateAction
| ApplyObjectUpdatesAction | InitializeFieldsAction
| DiscardObjectUpdatesAction | DiscardObjectUpdatesAction
| ReinstateObjectUpdatesAction | ReinstateObjectUpdatesAction
| RemoveObjectUpdatesAction | RemoveObjectUpdatesAction
| RemoveSingleObjectUpdateAction | RemoveFieldUpdateAction;
| ReplaceObjectUpdatesAction;

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 { import {
AddToObjectUpdatesAction, AddFieldUpdateAction,
ApplyObjectUpdatesAction,
DiscardObjectUpdatesAction, DiscardObjectUpdatesAction,
FieldChangeType,
InitializeFieldsAction,
ObjectUpdatesAction, ObjectUpdatesAction,
ObjectUpdatesActionTypes, ObjectUpdatesActionTypes,
ReinstateObjectUpdatesAction, ReinstateObjectUpdatesAction,
RemoveObjectUpdatesAction, RemoveFieldUpdateAction,
RemoveSingleObjectUpdateAction, ReplaceObjectUpdatesAction RemoveObjectUpdatesAction, SetEditableFieldUpdateAction
} from './object-updates.actions'; } from './object-updates.actions';
import { Operation } from 'fast-json-patch'; import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { 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 { export interface ObjectUpdatesEntry {
updates: Operation[]; fieldStates: FieldStates;
lastServerUpdate: number; fieldUpdates: FieldUpdates
lastModified: number; lastModified: Date;
discarded: boolean; // lastUpdate: Date;
} }
export interface ObjectUpdatesState { export interface ObjectUpdatesState {
[url: string]: ObjectUpdatesEntry; [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__`) // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null); const initialState = Object.create(null);
export function objectUpdatesReducer(state = initialState, action: ObjectUpdatesAction): ObjectUpdatesState { export function objectUpdatesReducer(state = initialState, action: ObjectUpdatesAction): ObjectUpdatesState {
let newState = state; let newState = state;
switch (action.type) { switch (action.type) {
case ObjectUpdatesActionTypes.REPLACE: { case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: {
newState = replaceObjectUpdates(state, action as ReplaceObjectUpdatesAction); newState = initializeFieldsUpdate(state, action as InitializeFieldsAction);
break; break;
} }
case ObjectUpdatesActionTypes.ADD: { case ObjectUpdatesActionTypes.ADD_FIELD: {
newState = addToObjectUpdates(state, action as AddToObjectUpdatesAction); newState = addFieldUpdate(state, action as AddFieldUpdateAction);
break;
}
case ObjectUpdatesActionTypes.APPLY: {
/* For now do nothing, handle in effect */
// return applyObjectUpdates(state, action as ApplyObjectUpdatesAction);
newState = state;
break; break;
} }
case ObjectUpdatesActionTypes.DISCARD: { case ObjectUpdatesActionTypes.DISCARD: {
@@ -54,86 +75,162 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
newState = removeObjectUpdates(state, action as RemoveObjectUpdatesAction); newState = removeObjectUpdates(state, action as RemoveObjectUpdatesAction);
break; break;
} }
case ObjectUpdatesActionTypes.REMOVE_SINGLE: { case ObjectUpdatesActionTypes.REMOVE_FIELD: {
newState = removeSingleObjectUpdates(state, action as RemoveSingleObjectUpdateAction); newState = removeFieldUpdate(state, action as RemoveFieldUpdateAction);
break; break;
} }
case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: {
// return directly, no need to change the lastModified date
return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction);
}
default: { default: {
return state; return state;
} }
} }
return setLastModified(newState, action.payload.url); // return setUpdated(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 newState; return newState;
} }
function removeSingleObjectUpdates(state: any, action: RemoveSingleObjectUpdateAction) { function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
const key: string = action.payload.url; const url: string = action.payload.url;
let newKeyState = state[key]; const fields: Identifiable[] = action.payload.fields;
if (hasValue(newKeyState)) { const lastModifiedServer: Date = action.payload.lastModified;
const newUpdates: Operation[] = Object.assign({}, newKeyState.updates); const fieldStates = createInitialFieldStates(fields);
if (hasValue(newUpdates[action.payload.fieldID])) { const newPageState = Object.assign(
delete newUpdates[action.payload.fieldID]; {},
} state[url],
newKeyState = Object.assign({}, state[key], { updates: newUpdates }); { fieldStates: fieldStates },
} { fieldUpdates: {} },
return Object.assign({}, state, newKeyState); { lastServerUpdate: lastModifiedServer }
);
return Object.assign({}, state, { [url]: newPageState });
} }
function setLastModified(state: any, url: string) { function addFieldUpdate(state: any, action: AddFieldUpdateAction) {
const newKeyState = Object.assign({}, state[url] || {}, { lastModified: Date.now() }); const url: string = action.payload.url;
return Object.assign({}, state, newKeyState); 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 { MetadataSchema } from './metadataschema.model';
import { autoserialize } from 'cerialize'; import { autoserialize } from 'cerialize';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
import { isNotEmpty } from '../../shared/empty.util';
export class MetadataField implements ListableObject { export class MetadataField implements ListableObject {
@autoserialize @autoserialize
@@ -20,4 +21,12 @@ export class MetadataField implements ListableObject {
@autoserialize @autoserialize
schema: MetadataSchema; 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 { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service'; import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service';
import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; 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 { createSelector, select, Store } from '@ngrx/store';
import { AppState } from '../../app.reducer'; import { AppState } from '../../app.reducer';
import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers'; import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers';
import { import {
MetadataRegistryCancelFieldAction, MetadataRegistryCancelFieldAction,
MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction, MetadataRegistryDeselectAllSchemaAction, MetadataRegistryCancelSchemaAction,
MetadataRegistryDeselectAllFieldAction,
MetadataRegistryDeselectAllSchemaAction,
MetadataRegistryDeselectFieldAction, MetadataRegistryDeselectFieldAction,
MetadataRegistryDeselectSchemaAction, MetadataRegistryDeselectSchemaAction,
MetadataRegistryEditFieldAction, MetadataRegistryEditFieldAction,
@@ -168,7 +174,7 @@ export class RegistryService {
public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataField>>> { public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataField>>> {
if (hasNoValue(pagination)) { if (hasNoValue(pagination)) {
pagination = { currentPage: 1, pageSize: Number.MAX_VALUE } as any; pagination = { currentPage: 1, pageSize: 10000 } as any;
} }
const requestObs = this.getMetadataFieldsRequestObs(pagination); 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 { autoserialize } from 'cerialize';
import * as uuidv4 from 'uuid/v4';
export class Metadatum { export class Metadatum {
uuid: string = uuidv4();
/** /**
* The metadata field of this Metadatum * 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)" [action]="action" (keydown)="onKeydown($event)"
(keydown.arrowdown)="shiftFocusDown($event)" (keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()" (keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close()"> (dsClickOutside)="close()">
<input #inputField type="text" [(ngModel)]="ngModel" [name]="name" <input #inputField type="text" [(ngModel)]="value" [name]="name"
class="form-control suggestion_input" class="form-control suggestion_input"
[dsDebounce]="debounceTime" (onDebounce)="find($event)" [dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder" [placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"/> [ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/> <input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}"> <div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<ul class="list-unstyled"> <div>
<li *ngFor="let suggestionOption of suggestions"> <div *ngFor="let suggestionOption of suggestions">
<a href="#" class="d-block dropdown-item" (click)="onClickSuggestion(suggestionOption.value)" #suggestion> <a href="#" class="d-block dropdown-item" (click)="onClickSuggestion(suggestionOption.value)" #suggestion>
<span [innerHTML]="suggestionOption.displayValue"></span> <span [innerHTML]="suggestionOption.displayValue"></span>
</a> </a>
</li> </div>
</ul> </div>
</div> </div>
</form> </form>

View File

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

View File

@@ -1,6 +1,6 @@
import { import {
Component, Component,
ElementRef, EventEmitter, ElementRef, EventEmitter, forwardRef,
Input, Input,
Output, Output,
QueryList, SimpleChanges, QueryList, SimpleChanges,
@@ -9,21 +9,30 @@ import {
} from '@angular/core'; } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { hasValue, isNotEmpty } from '../empty.util'; import { hasValue, isNotEmpty } from '../empty.util';
import { InputSuggestion } from './input-suggestions.model';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({ @Component({
selector: 'ds-input-suggestions', selector: 'ds-input-suggestions',
styleUrls: ['./input-suggestions.component.scss'], 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 * Component representing a form with a autocomplete functionality
*/ */
export class InputSuggestionsComponent { export class InputSuggestionsComponent implements ControlValueAccessor {
/** /**
* The suggestions that should be shown * 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 * The time waited to detect if any other input will follow before requesting the suggestions
@@ -45,16 +54,6 @@ export class InputSuggestionsComponent {
*/ */
@Input() name; @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 * Output for when the form is submitted
*/ */
@@ -94,6 +93,15 @@ export class InputSuggestionsComponent {
*/ */
@ViewChildren('suggestion') resultViews: QueryList<ElementRef>; @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 * 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 * 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) { onClickSuggestion(data) {
this.value = data;
this.clickSuggestion.emit(data); this.clickSuggestion.emit(data);
this.close(); this.close();
this.blockReopen = true; this.blockReopen = true;
@@ -188,4 +197,31 @@ export class InputSuggestionsComponent {
this.blockReopen = false; 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(''), success(title: any = observableOf(''),
content: any = observableOf(''), content: any = observableOf(''),
options: NotificationOptions = this.getDefaultOptions(), options: Partial<NotificationOptions> = {},
html: boolean = false): INotification { 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); this.add(notification);
return notification; return notification;
} }
error(title: any = observableOf(''), error(title: any = observableOf(''),
content: any = observableOf(''), content: any = observableOf(''),
options: NotificationOptions = this.getDefaultOptions(), options: Partial<NotificationOptions> = {},
html: boolean = false): INotification { 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); this.add(notification);
return notification; return notification;
} }
info(title: any = observableOf(''), info(title: any = observableOf(''),
content: any = observableOf(''), content: any = observableOf(''),
options: NotificationOptions = this.getDefaultOptions(), options: Partial<NotificationOptions> = {},
html: boolean = false): INotification { 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); this.add(notification);
return notification; return notification;
} }
@@ -56,7 +59,8 @@ export class NotificationsService {
content: any = observableOf(''), content: any = observableOf(''),
options: NotificationOptions = this.getDefaultOptions(), options: NotificationOptions = this.getDefaultOptions(),
html: boolean = false): INotification { 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); this.add(notification);
return 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 { EditComColPageComponent } from './comcol-forms/edit-comcol-page/edit-comcol-page.component';
import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component';
import { LangSwitchComponent } from './lang-switch/lang-switch.component'; import { LangSwitchComponent } from './lang-switch/lang-switch.component';
import { ObjectValuesPipe } from './utils/object-values-pipe';
const MODULES = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -124,6 +125,7 @@ const PIPES = [
EmphasizePipe, EmphasizePipe,
CapitalizePipe, CapitalizePipe,
ObjectKeysPipe, ObjectKeysPipe,
ObjectValuesPipe,
ConsolePipe 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; $blue: #2B4E72 !default;
$green: #94BA65 !default; $green: #94BA65 !default;
$cyan: #2790B0 !default; $cyan: #2790B0 !default;
$yellow: #EBBB54 !default; $yellow: #ec9433 !default;
$red: #CF4444 !default; $red: #CF4444 !default;
$dark: darken($blue, 17%) !default; $dark: darken($blue, 17%) !default;
@@ -56,3 +56,4 @@ $grid-breakpoints: (
xl: (1200px - $collapsed-sidebar-width) xl: (1200px - $collapsed-sidebar-width)
) !default; ) !default;
$yiq-contrasted-threshold: 165 !default;