diff --git a/config/environment.default.js b/config/environment.default.js index d46dc10dee..788a1fb827 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -79,5 +79,8 @@ module.exports = { code: 'nl', label: 'Nederlands', active: false, - }] + }], + item: { + + } }; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index a87d36ef9b..cd7e1276f1 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -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" + } + } } } }, diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.html b/src/app/+item-page/edit-item-page/edit-item-page.component.html index 46a8126b05..82a4b0c283 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.html +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.html @@ -14,9 +14,10 @@ - + - + + diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index adbcf6b0df..1c0c747ffb 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -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 { diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html index 5a5e16383a..279552565c 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -1,42 +1,47 @@ - - - {{metadata.key}} - - - - - - - - - - - - - - -
- {{metadata.value}} -
-
- -
- - -
- {{metadata.language}} -
-
- -
- - -
- - -
-
- - -
- +
+ + +
+ {{metadata?.key}} +
+
+ +
+ + +
+ {{metadata?.value}} +
+
+ +
+ + +
+ {{metadata?.language}} +
+
+ +
+ + +
+ + + + +
+ +
\ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss index 58c24635e6..e69de29bb2 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss @@ -1,3 +0,0 @@ -textarea, input, select { - width: 100%; -} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts new file mode 100644 index 0000000000..0d7006b85c --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -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; +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 }); + }); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts index 1139415ecc..8902125985 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts @@ -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 = new EventEmitter(); - @Output() mdRemove: EventEmitter = 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; + + /** + * The current suggestions for the metadatafield when editing + */ + metadataFieldSuggestions: BehaviorSubject = 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 { + 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 { + 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 { + 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 { + return observableOf(this.fieldUpdate.changeType >= 0); + } + + protected isNotEmpty(value): boolean { return isNotEmpty(value); } - - update() { - this.mdUpdate.emit(); - } - - remove() { - this.mdRemove.emit() - } } diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html index f1c45ddda7..bc8e52d48b 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -1,12 +1,55 @@
diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts new file mode 100644 index 0000000000..bbeda39716 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts @@ -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; +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 }); + }); + }); + }); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 9fe1d76a03..6c7b310ce2 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -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) { - this.updateItem = Object.assign({}, this.item); + /** + * The current values and updates for all this item's metadata fields + */ + updates$: Observable; + /** + * 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 = this.objectUpdatesService.getUpdatedFields(this.route, this.item.metadata) as Observable; + metadata$.pipe( + first(), + switchMap((metadata: Metadatum[]) => { + const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata }); + return this.itemService.update(updatedItem); + }), + getSucceededRemoteData() + ).subscribe( + (rd: RemoteData) => { + 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 { + return this.objectUpdatesService.hasUpdates(this.route); + } + + /** + * Checks whether or not the item is currently reinstatable + */ + isReinstatable(): Observable { + 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(); + } + } + ); + } } diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 8c1f317bb7..1ce4f3ad88 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -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(); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index fd5a75e7d1..1675dd051a 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -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 = observableOf([]); + filterSearchResults: Observable = observableOf([]); /** * Emits the active values for this filter @@ -266,7 +267,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { map( (rd: RemoteData>) => { return rd.payload.page.map((facet) => { - return { displayValue: this.getDisplayValue(facet, data), value: facet.value } + return { + displayValue: this.getDisplayValue(facet, data), + value: facet.value + } }) } )) diff --git a/src/app/+search-page/search-service/facet-value.model.ts b/src/app/+search-page/search-service/facet-value.model.ts index a597528d50..0f673f3485 100644 --- a/src/app/+search-page/search-service/facet-value.model.ts +++ b/src/app/+search-page/search-service/facet-value.model.ts @@ -1,4 +1,3 @@ - import { autoserialize, autoserializeAs } from 'cerialize'; /** diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index cab3a21e5b..7cfbcdd252 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -50,13 +50,13 @@ export class RemoteDataBuildService { const payload$ = observableCombineLatest( href$.pipe( - switchMap((href: string) => this.objectCache.getBySelfLink>(href)), + switchMap((href: string) => this.objectCache.getBySelfLink(href)), startWith(undefined)), requestEntry$.pipe( getResourceLinksFromResponse(), switchMap((resourceSelfLinks: string[]) => { if (isNotEmpty(resourceSelfLinks)) { - return this.objectCache.getBySelfLink(resourceSelfLinks[0]); + return this.objectCache.getBySelfLink(resourceSelfLinks[0]); } else { return observableOf(undefined); } diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index 3e3715d186..c86a0d5654 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -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: [] }; /** diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index c9a352c545..a23516aa45 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -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 ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index aba53e2a7f..1039ded993 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -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, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 1843e10671..760d3ddeaf 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -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 = { 'cache/object': objectCacheReducer, 'cache/syncbuffer': serverSyncBufferReducer, + 'cache/object-updates': objectUpdatesReducer, 'data/request': requestReducer, 'index': indexReducer, - 'auth': authReducer + 'auth': authReducer, }; -export const coreSelector = createFeatureSelector('core'); +export const coreSelector = createFeatureSelector('core'); \ No newline at end of file diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 54f7fba190..96b85b9179 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -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; diff --git a/src/app/core/data/object-updates/object-updates.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts new file mode 100644 index 0000000000..a81a0665bf --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -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; + 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); + // }); + // + // }); +}); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts new file mode 100644 index 0000000000..f89700f6fe --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -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 + } = {}; + + @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(); + } + 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) { + } + +} diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index 486a7b143d..4345373bfa 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -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; } diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts new file mode 100644 index 0000000000..2f3e31eb7c --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -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 { + return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); +} + +function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector { + return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]); +} + +@Injectable() +export class ObjectUpdatesService { + constructor(private store: Store) { + + } + + 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 { + return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url))); + } + + getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { + 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 { + 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 { + 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 { + return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates))); + } + + isReinstatable(route: string): Observable { + return this.hasUpdates(route + OBJECT_UPDATES_TRASH_PATH) + } + + getLastModified(url: string): Observable { + return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); + } +} diff --git a/src/app/core/metadata/metadatafield.model.ts b/src/app/core/metadata/metadatafield.model.ts index f9b5155649..36f97d8d6f 100644 --- a/src/app/core/metadata/metadatafield.model.ts +++ b/src/app/core/metadata/metadatafield.model.ts @@ -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; + } } diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 4bd0dabb70..539cb6a596 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -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>> { 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>> { + /** + * This should come directly from the server in the future + */ + return this.getAllMetadataFields().pipe( + map((rd: RemoteData>) => { + const filteredFields: MetadataField[] = rd.payload.page.filter( + (field: MetadataField) => field.toString().indexOf(query) >= 0 + ); + const page: PaginatedList = new PaginatedList(new PageInfo(), filteredFields) + return Object.assign({}, rd, { payload: page }); + }) + ); + } } diff --git a/src/app/core/shared/metadatum.model.ts b/src/app/core/shared/metadatum.model.ts index ca1f3d8591..02f6e00765 100644 --- a/src/app/core/shared/metadatum.model.ts +++ b/src/app/core/shared/metadatum.model.ts @@ -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 */ diff --git a/src/app/shared/input-suggestions/input-suggestions.component.html b/src/app/shared/input-suggestions/input-suggestions.component.html index bbe090dac0..2ee80e576a 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.html +++ b/src/app/shared/input-suggestions/input-suggestions.component.html @@ -1,21 +1,21 @@ -
- +
\ No newline at end of file diff --git a/src/app/shared/input-suggestions/input-suggestions.component.scss b/src/app/shared/input-suggestions/input-suggestions.component.scss index bea74cf7af..f2587e1b6f 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.scss +++ b/src/app/shared/input-suggestions/input-suggestions.component.scss @@ -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; } diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 8a8069d71e..b9f068b30f 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -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; + /** + * 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); + } } diff --git a/src/app/shared/input-suggestions/input-suggestions.model.ts b/src/app/shared/input-suggestions/input-suggestions.model.ts new file mode 100644 index 0000000000..34ab769587 --- /dev/null +++ b/src/app/shared/input-suggestions/input-suggestions.model.ts @@ -0,0 +1,4 @@ +export interface InputSuggestion { + displayValue: string, + value: string +} diff --git a/src/app/shared/notifications/notifications.service.ts b/src/app/shared/notifications/notifications.service.ts index 55df6a4f7f..bb57aebd78 100644 --- a/src/app/shared/notifications/notifications.service.ts +++ b/src/app/shared/notifications/notifications.service.ts @@ -27,27 +27,30 @@ export class NotificationsService { success(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, 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 = {}, 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 = {}, 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; } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 53cf15ab6e..c3e7051304 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -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 ]; diff --git a/src/app/shared/utils/object-values-pipe.ts b/src/app/shared/utils/object-values-pipe.ts new file mode 100644 index 0000000000..79efd1cb76 --- /dev/null +++ b/src/app/shared/utils/object-values-pipe.ts @@ -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; + } +} diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index 2739503284..d24811b382 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -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;