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 1c0c747ffb..98904517f9 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 @@ -1,17 +1,17 @@ -import {NgModule} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {SharedModule} from '../../shared/shared.module'; -import {EditItemPageRoutingModule} from './edit-item-page.routing.module'; -import {EditItemPageComponent} from './edit-item-page.component'; -import {ItemStatusComponent} from './item-status/item-status.component'; -import {ItemOperationComponent} from './item-operation/item-operation.component'; -import {ModifyItemOverviewComponent} from './modify-item-overview/modify-item-overview.component'; -import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component'; -import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component'; -import {AbstractSimpleItemActionComponent} from './simple-item-action/abstract-simple-item-action.component'; -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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../../shared/shared.module'; +import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; +import { EditItemPageComponent } from './edit-item-page.component'; +import { ItemStatusComponent } from './item-status/item-status.component'; +import { ItemOperationComponent } from './item-operation/item-operation.component'; +import { ModifyItemOverviewComponent } from './modify-item-overview/modify-item-overview.component'; +import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component'; +import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component'; +import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract-simple-item-action.component'; +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 { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; 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 index 0d7006b85c..aad94931dd 100644 --- 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 @@ -105,6 +105,36 @@ describe('EditInPlaceFieldComponent', () => { }); }); + describe('changeType is UPDATE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.UPDATE; + fixture.detectChanges(); + }); + it('the div should have class table-warning', () => { + expect(el.classList).toContain('table-warning'); + }); + }); + + describe('changeType is ADD', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + fixture.detectChanges(); + }); + it('the div should have class table-success', () => { + expect(el.classList).toContain('table-success'); + }); + }); + + describe('changeType is REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.REMOVE; + fixture.detectChanges(); + }); + it('the div should have class table-danger', () => { + expect(el.classList).toContain('table-danger'); + }); + }); + describe('setEditable', () => { const editable = false; beforeEach(() => { @@ -116,6 +146,30 @@ describe('EditInPlaceFieldComponent', () => { }); }); + describe('editable is true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + fixture.detectChanges(); + }); + it('the div should contain input fields or textareas', () => { + const inputField = de.queryAll(By.css('input')); + const textAreas = de.queryAll(By.css('textarea')); + expect(inputField.length + textAreas.length).toBeGreaterThan(0); + }); + }); + + describe('editable is false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + fixture.detectChanges(); + }); + it('the div should contain no input fields or textareas', () => { + const inputField = de.queryAll(By.css('input')); + const textAreas = de.queryAll(By.css('textarea')); + expect(inputField.length + textAreas.length).toBe(0); + }); + }); + describe('remove', () => { beforeEach(() => { comp.remove(); @@ -225,10 +279,98 @@ describe('EditInPlaceFieldComponent', () => { }); }); + describe('when canSetEditable emits true', () => { + beforeEach(() => { + spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); + }); + it('the div should contain a edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')); + expect(editIcon).not.toBeNull(); + }); + }); + + describe('when canSetEditable emits false', () => { + beforeEach(() => { + spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should not contain a edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')); + expect(editIcon).toBeNull(); + }); + }); + + describe('when canSetUneditable emits true', () => { + beforeEach(() => { + spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should contain a check icon', () => { + const checkIcon = de.query(By.css('i.fa-check')); + expect(checkIcon).not.toBeNull(); + }); + }); + + describe('when canSetUneditable emits false', () => { + beforeEach(() => { + spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should not contain a check icon', () => { + const checkIcon = de.query(By.css('i.fa-check')); + expect(checkIcon).toBeNull(); + }); + }); + + describe('when canRemove emits true', () => { + beforeEach(() => { + spyOn(comp, 'canRemove').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should contain a trash icon', () => { + const trashIcon = de.query(By.css('i.fa-trash-alt')); + expect(trashIcon).not.toBeNull(); + }); + }); + + describe('when canRemove emits false', () => { + beforeEach(() => { + spyOn(comp, 'canRemove').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should not contain a trash icon', () => { + const trashIcon = de.query(By.css('i.fa-trash-alt')); + expect(trashIcon).toBeNull(); + }); + }); + + describe('when canUndo emits true', () => { + beforeEach(() => { + spyOn(comp, 'canUndo').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should contain a undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')); + expect(undoIcon).not.toBeNull(); + }); + }); + + describe('when canUndo emits false', () => { + beforeEach(() => { + spyOn(comp, 'canUndo').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should not contain a undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')); + expect(undoIcon).toBeNull(); + }); + }); + describe('canRemove', () => { describe('when editable is currently true', () => { beforeEach(() => { comp.editable = observableOf(true); + fixture.detectChanges(); }); it('canRemove should return an observable emitting false', () => { const expected = '(a|)'; 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 8902125985..38470c54c4 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 @@ -3,14 +3,14 @@ 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'; +import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; @Component({ selector: 'ds-edit-in-place-field', 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 index bbeda39716..b6c53fdbf1 100644 --- 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 @@ -9,7 +9,7 @@ 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 { TranslateModule } from '@ngx-translate/core'; import { ItemDataService } from '../../../core/data/item-data.service'; import { By } from '@angular/platform-browser'; import { @@ -81,7 +81,8 @@ 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)) + update: observableOf(new RemoteData(false, false, true, undefined, item)), + commitUpdates: {} }); scheduler = getTestScheduler(); objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', 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 6c7b310ce2..d2231e615d 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 @@ -11,7 +11,7 @@ import { Identifiable } from '../../../core/data/object-updates/object-updates.reducer'; import { Metadatum } from '../../../core/shared/metadatum.model'; -import { first, switchMap } from 'rxjs/operators'; +import { first, switchMap, tap } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -121,6 +121,7 @@ export class ItemMetadataComponent implements OnInit { const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata }); return this.itemService.update(updatedItem); }), + tap(() => this.itemService.commitUpdates()), getSucceededRemoteData() ).subscribe( (rd: RemoteData) => { diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index ac56436539..59605da4d8 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -41,6 +41,7 @@ import { CacheableObject } from '../cache/object-cache.reducer'; import { RequestEntry } from './request.reducer'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { ChangeAnalyzer } from './change-analyzer'; +import { RestRequestMethod } from './rest-request-method'; export abstract class DataService { protected abstract requestService: RequestService; @@ -228,4 +229,12 @@ export abstract class DataService { ); } + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod) { + this.requestService.commit(method); + } + } 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 96b85b9179..5f76d6dde9 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -2,6 +2,9 @@ import { type } from '../../../shared/ngrx/type'; import { Action } from '@ngrx/store'; import { Identifiable } from './object-updates.reducer'; import { INotification } from '../../../shared/notifications/models/notification.model'; +/** + * The list of ObjectUpdatesAction type definitions + */ export const ObjectUpdatesActionTypes = { INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), @@ -14,12 +17,19 @@ export const ObjectUpdatesActionTypes = { }; /* tslint:disable:max-classes-per-file */ + +/** + * Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store + */ export enum FieldChangeType { UPDATE = 0, ADD = 1, REMOVE = 2 } +/** + * An ngrx action to initialize a new page's fields in the ObjectUpdates state + */ export class InitializeFieldsAction implements Action { type = ObjectUpdatesActionTypes.INITIALIZE_FIELDS; payload: { @@ -28,6 +38,14 @@ export class InitializeFieldsAction implements Action { lastModified: Date }; + /** + * Create a new InitializeFieldsAction + * + * @param url + * the unique url of the page for which the fields are being initialized + * @param fields The identifiable fields of which the updates are kept track of + * @param lastModified The last modified date of the object that belongs to the page + */ constructor( url: string, fields: Identifiable[], @@ -37,6 +55,9 @@ export class InitializeFieldsAction implements Action { } } +/** + * An ngrx action to add a new field update in the ObjectUpdates state for a certain page url + */ export class AddFieldUpdateAction implements Action { type = ObjectUpdatesActionTypes.ADD_FIELD; payload: { @@ -45,6 +66,14 @@ export class AddFieldUpdateAction implements Action { changeType: FieldChangeType, }; + /** + * Create a new AddFieldUpdateAction + * + * @param url + * the unique url of the page for which a field update is added + * @param field The identifiable field of which a new update is added + * @param changeType The update's change type + */ constructor( url: string, field: Identifiable, @@ -53,6 +82,9 @@ export class AddFieldUpdateAction implements Action { } } +/** + * An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url + */ export class SetEditableFieldUpdateAction implements Action { type = ObjectUpdatesActionTypes.SET_EDITABLE_FIELD; payload: { @@ -61,6 +93,14 @@ export class SetEditableFieldUpdateAction implements Action { editable: boolean, }; + /** + * Create a new SetEditableFieldUpdateAction + * + * @param url + * the unique url of the page + * @param fieldUUID The UUID of the field of which + * @param editable The new editable value for the field + */ constructor( url: string, fieldUUID: string, @@ -69,13 +109,23 @@ export class SetEditableFieldUpdateAction implements Action { } } +/** + * An ngrx action to discard all existing updates in the ObjectUpdates state for a certain page url + */ export class DiscardObjectUpdatesAction implements Action { type = ObjectUpdatesActionTypes.DISCARD; payload: { url: string, - notification + notification: INotification }; + /** + * Create a new DiscardObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be discarded + * @param notification The notification that is raised when changes are discarded + */ constructor( url: string, notification: INotification @@ -84,12 +134,21 @@ export class DiscardObjectUpdatesAction implements Action { } } +/** + * An ngrx action to reinstate all previously discarded updates in the ObjectUpdates state for a certain page url + */ export class ReinstateObjectUpdatesAction implements Action { type = ObjectUpdatesActionTypes.REINSTATE; payload: { url: string }; + /** + * Create a new ReinstateObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be reinstated + */ constructor( url: string ) { @@ -97,12 +156,21 @@ export class ReinstateObjectUpdatesAction implements Action { } } +/** + * An ngrx action to remove all previously discarded updates in the ObjectUpdates state for a certain page url + */ export class RemoveObjectUpdatesAction implements Action { type = ObjectUpdatesActionTypes.REMOVE; payload: { url: string }; + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be removed + */ constructor( url: string ) { @@ -117,6 +185,13 @@ export class RemoveFieldUpdateAction implements Action { uuid: string }; + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which a field's change should be removed + * @param uuid The UUID of the field for which the change should be removed + */ constructor( url: string, uuid: string 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 index a81a0665bf..79b1b2df72 100644 --- a/src/app/core/data/object-updates/object-updates.effects.spec.ts +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -1,55 +1,122 @@ -import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs'; +import { async, TestBed } from '@angular/core/testing'; +import { Observable, Subject } 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'; +import { + DiscardObjectUpdatesAction, + ObjectUpdatesAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + RemoveObjectUpdatesAction +} from './object-updates.actions'; +import { + INotification, + Notification +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { filter } from 'rxjs/operators'; +import { hasValue } from '../../../shared/empty.util'; -fdescribe('ObjectUpdatesEffects', () => { +describe('ObjectUpdatesEffects', () => { let updatesEffects: ObjectUpdatesEffects; let actions: Observable; - const testURL = 'www.dspace.org/dspace7'; - beforeEach(() => { + let testURL = 'www.dspace.org/dspace7'; + let testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + beforeEach(async(() => { TestBed.configureTestingModule({ providers: [ ObjectUpdatesEffects, provideMockActions(() => actions), { - provide: NotificationsService, useClass: { + provide: NotificationsService, + useValue: { remove: (notification) => { /* empty */ } } }, - // other providers ], }); + })); + beforeEach(() => { + testURL = 'www.dspace.org/dspace7'; + testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; updatesEffects = TestBed.get(ObjectUpdatesEffects); + (updatesEffects as any).actionMap[testURL] = new Subject(); }); describe('mapLastActions$', () => { describe('When any ObjectUpdatesAction is triggered', () => { - const action = new RemoveObjectUpdatesAction(testURL); + let action; + let emittedAction; + beforeEach(() => { + action = new RemoveObjectUpdatesAction(testURL); + }); it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { + action = new RemoveObjectUpdatesAction(testURL); actions = hot('--a-', { a: action }); + (updatesEffects as any).actionMap[testURL].subscribe((act) => emittedAction = act); + const expected = cold('--b-', { b: undefined }); - const expected = cold('--b-', { b: action }); - - expect((updatesEffects as any).actionMap[testURL]).toBeObservable(expected); + expect(updatesEffects.mapLastActions$).toBeObservable(expected); + expect(emittedAction).toBe(action); }); }); }); - // 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); - // }); - // - // }); + describe('removeAfterDiscardOrReinstateOnUndo$', () => { + describe('When an ObjectUpdatesActionTypes.DISCARD action is triggered', () => { + let infoNotification: INotification; + let removeAction; + describe('When there is no user interactions before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 0; + removeAction = new RemoveObjectUpdatesAction(testURL) + }); + it('should return a RemoveObjectUpdatesAction', () => { + actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( + filter(((action) => hasValue(action)))) + .subscribe((t) => { + expect(t).toEqual(removeAction); + } + ) + ; + }); + }); + + describe('When there a REINSTATE action is fired before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 10; + }); + it('should return an action with type NO_ACTION', () => { + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { + expect(t).toEqual({ type: 'NO_ACTION' }); + } + ); + }); + }); + + describe('When there any ObjectUpdates action - other than REINSTATE - is fired before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 10; + }); + it('should return a RemoveObjectUpdatesAction', () => { + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) }); + + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => + expect(t).toEqual(new RemoveObjectUpdatesAction(testURL)) + ); + }); + }); + }); + }); }); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index f89700f6fe..ae49071dc1 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -3,15 +3,23 @@ import { Actions, Effect, ofType } from '@ngrx/effects'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, - ObjectUpdatesActionTypes + ObjectUpdatesActionTypes, + RemoveObjectUpdatesAction } from './object-updates.actions'; -import { map } from 'rxjs/operators'; -import { Subject } from 'rxjs'; +import { delay, map, switchMap, take, tap } from 'rxjs/operators'; +import { of as observableOf, race as observableRace, Subject } from 'rxjs'; import { hasNoValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +/** + * NGRX effects for ObjectUpdatesActions + */ @Injectable() export class ObjectUpdatesEffects { + /** + * Map that keeps track of the latest ObjectUpdatesAction for each page's url + */ private actionMap: { /* Use Subject instead of BehaviorSubject: we only want Actions that are fired while we're listening @@ -20,6 +28,9 @@ export class ObjectUpdatesEffects { [url: string]: Subject } = {}; + /** + * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key + */ @Effect({ dispatch: false }) mapLastActions$ = this.actions$ .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), @@ -33,31 +44,44 @@ export class ObjectUpdatesEffects { ) ); - // @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' } - // } - // ) - // ) - // ) - // } - // ) - // ); + /** + * Effect that checks whether the removeAction's notification timeout ends before a user triggers another ObjectUpdatesAction + * When no ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned + * When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned + * When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned + */ + @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 a user action + this.actionMap[url].pipe( + take(1), + tap(() => this.notificationsService.remove(notification)), + map((updateAction: ObjectUpdatesAction) => { + if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { + // If someone reinstated, do nothing, just let the reinstating happen + return { type: 'NO_ACTION' } + } else { + // If someone performed another action, assume the user does not want to reinstate and remove all changes + return new RemoveObjectUpdatesAction(action.payload.url); + } + }) + ) + ) + } + ) + ); + + constructor(private actions$: Actions, + private notificationsService: NotificationsService) { - constructor(private actions$: Actions, private notificationsService: NotificationsService) { } } diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts new file mode 100644 index 0000000000..377b3ab1b4 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -0,0 +1,256 @@ +import * as deepFreeze from 'deep-freeze'; +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + SetEditableFieldUpdateAction +} from './object-updates.actions'; +import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; + +class NullAction extends RemoveFieldUpdateAction { + type = null; + payload = null; + + constructor() { + super(null, null); + } +} + +const identifiable1 = { + uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', + key: 'dc.contributor.author', + language: null, + value: 'Smith, John' +}; + +const identifiable1update = { + uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', + key: 'dc.contributor.author', + language: null, + value: 'Smith, James' +}; +const identifiable2 = { + uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241', + key: 'dc.title', + language: null, + value: 'New title' +}; +const identifiable3 = { + uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e', + key: 'dc.description.abstract', + language: null, + value: 'Unchanged value' +}; + +const modDate = new Date(2010, 2, 11); +const uuid = identifiable1.uuid; +const url = 'test-object.url/edit'; +describe('objectUpdatesReducer', () => { + const testState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false + }, + [identifiable2.uuid]: { + editable: false, + isNew: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.title', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + const discardedTestState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false + }, + [identifiable2.uuid]: { + editable: false, + isNew: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false + }, + }, + lastModified: modDate + }, + [url + OBJECT_UPDATES_TRASH_PATH]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false + }, + [identifiable2.uuid]: { + editable: false, + isNew: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.title', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + deepFreeze(testState); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = objectUpdatesReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it('should start with an empty object', () => { + const action = new NullAction(); + const initialState = objectUpdatesReducer(undefined, action); + + expect(initialState).toEqual({}); + }); + + it('should perform the INITIALIZE_FIELDS action without affecting the previous state', () => { + const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => { + const action = new SetEditableFieldUpdateAction(url, uuid, false); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the ADD_FIELD action without affecting the previous state', () => { + const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the DISCARD action without affecting the previous state', () => { + const action = new DiscardObjectUpdatesAction(url, null); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REINSTATE action without affecting the previous state', () => { + const action = new ReinstateObjectUpdatesAction(url); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REMOVE action without affecting the previous state', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REMOVE_FIELD action without affecting the previous state', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { + const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); + + const expectedState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: false, + isNew: false + }, + [identifiable3.uuid]: { + editable: false, + isNew: false + }, + }, + fieldUpdates: {}, + lastModified: modDate + } + }; + const newState = objectUpdatesReducer(testState, action); + expect(newState).toEqual(expectedState); + }); + + it('should set the given field\'s fieldStates when the SET_EDITABLE_FIELD action is dispatched, based on the payload', () => { + const action = new SetEditableFieldUpdateAction(url, identifiable3.uuid, true); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldStates[identifiable3.uuid].editable).toBeTruthy(); + }); + + it('should add a given field\'s update to the state when the ADD_FIELD action is dispatched, based on the payload', () => { + const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates[identifiable1.uuid].field).toEqual(identifiable1update); + expect(newState[url].fieldUpdates[identifiable1.uuid].changeType).toEqual(FieldChangeType.UPDATE); + }); + + it('should discard a given url\'s updates from the state when the DISCARD action is dispatched, based on the payload', () => { + const action = new DiscardObjectUpdatesAction(url, null); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates).toEqual({}); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toEqual(testState[url]); + }); + + it('should reinstate a given url\'s updates from the state when the REINSTATE action is dispatched, based on the payload', () => { + const action = new ReinstateObjectUpdatesAction(url); + + const newState = objectUpdatesReducer(discardedTestState, action); + expect(newState).toEqual(testState); + }); + + it('should remove a given url\'s updates from the state when the REMOVE action is dispatched, based on the payload', () => { + const action = new RemoveObjectUpdatesAction(url); + + const newState = objectUpdatesReducer(discardedTestState, action); + expect(newState[url].fieldUpdates).toBeUndefined(); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined(); + }); + + it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates[uuid]).toBeUndefined(); + }); +}); 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 4345373bfa..c46438bd90 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -52,45 +52,45 @@ 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); +/** + * Reducer method to calculate the next ObjectUpdates state, based on the current state and the ObjectUpdatesAction + * @param state The current state + * @param action The action to perform on the current state + */ export function objectUpdatesReducer(state = initialState, action: ObjectUpdatesAction): ObjectUpdatesState { - let newState = state; switch (action.type) { case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: { - newState = initializeFieldsUpdate(state, action as InitializeFieldsAction); - break; + return initializeFieldsUpdate(state, action as InitializeFieldsAction); } case ObjectUpdatesActionTypes.ADD_FIELD: { - newState = addFieldUpdate(state, action as AddFieldUpdateAction); - break; + return addFieldUpdate(state, action as AddFieldUpdateAction); } case ObjectUpdatesActionTypes.DISCARD: { - newState = discardObjectUpdates(state, action as DiscardObjectUpdatesAction); - break; + return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); } case ObjectUpdatesActionTypes.REINSTATE: { - newState = reinstateObjectUpdates(state, action as ReinstateObjectUpdatesAction); - break; + return reinstateObjectUpdates(state, action as ReinstateObjectUpdatesAction); } case ObjectUpdatesActionTypes.REMOVE: { - newState = removeObjectUpdates(state, action as RemoveObjectUpdatesAction); - break; + return removeObjectUpdates(state, action as RemoveObjectUpdatesAction); } case ObjectUpdatesActionTypes.REMOVE_FIELD: { - newState = removeFieldUpdate(state, action as RemoveFieldUpdateAction); - break; + return removeFieldUpdate(state, action as RemoveFieldUpdateAction); } case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: { - // return directly, no need to change the lastModified date return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction); } default: { return state; } } - // return setUpdated(newState, action.payload.url); - return newState; } +/** + * Initialize the state for a specific url and store all its fields in the store + * @param state The current state + * @param action The action to perform on the current state + */ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { const url: string = action.payload.url; const fields: Identifiable[] = action.payload.fields; @@ -101,11 +101,16 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { state[url], { fieldStates: fieldStates }, { fieldUpdates: {} }, - { lastServerUpdate: lastModifiedServer } + { lastModified: lastModifiedServer } ); return Object.assign({}, state, { [url]: newPageState }); } +/** + * Add a new update for a specific field to the store + * @param state The current state + * @param action The action to perform on the current state + */ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { const url: string = action.payload.url; const field: Identifiable = action.payload.field; @@ -130,6 +135,11 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { return Object.assign({}, state, { [url]: newPageState }); } +/** + * Discard all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { const url: string = action.payload.url; const pageState: ObjectUpdatesEntry = state[url]; @@ -149,6 +159,11 @@ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); } +/** + * Reinstate all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction) { const url: string = action.payload.url; const trashState = state[url + OBJECT_UPDATES_TRASH_PATH]; @@ -158,17 +173,32 @@ function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction return newState; } +/** + * Remove all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ function removeObjectUpdates(state: any, action: RemoveObjectUpdatesAction) { const url: string = action.payload.url; return removeObjectUpdatesByURL(state, url); } +/** + * Remove all updates for a specific url in the store + * @param state The current state + * @param action The action to perform on the current state + */ function removeObjectUpdatesByURL(state: any, url: string) { const newState = Object.assign({}, state); delete newState[url + OBJECT_UPDATES_TRASH_PATH]; return newState; } +/** + * Discard the update for a specific action's url and field UUID in the store + * @param state The current state + * @param action The action to perform on the current state + */ function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { const url: string = action.payload.url; const uuid: string = action.payload.uuid; @@ -196,11 +226,12 @@ function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { 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 }); -} - +/** + * Determine the most prominent FieldChangeType, ordered as follows: + * undefined < UPDATE < ADD < REMOVE + * @param oldType The current type + * @param newType The new type that should possibly override the new type + */ function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType): FieldChangeType { if (hasNoValue(newType)) { return oldType; @@ -211,6 +242,11 @@ function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType) return oldType.valueOf() > newType.valueOf() ? oldType : newType; } +/** + * Set the state of a specific action's url and uuid to false or true + * @param state The current state + * @param action The action to perform on the current state + */ function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction) { const url: string = action.payload.url; const uuid: string = action.payload.uuid; @@ -228,6 +264,10 @@ function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction return Object.assign({}, state, { [url]: newPageState }); } +/** + * Method to create an initial FieldStates object based on a list of Identifiable objects + * @param fields Identifiable objects + */ function createInitialFieldStates(fields: Identifiable[]) { const uuids = fields.map((field: Identifiable) => field.uuid); const fieldStates = {}; diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts new file mode 100644 index 0000000000..4f0393c641 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -0,0 +1,233 @@ +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { ObjectUpdatesService } from './object-updates.service'; +import { + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + SetEditableFieldUpdateAction +} from './object-updates.actions'; +import { of as observableOf } from 'rxjs'; +import { Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; + +describe('ObjectUpdatesService', () => { + let service: ObjectUpdatesService; + let store: Store; + const value = 'test value'; + const url = 'test-url.com/dspace'; + const identifiable1 = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320' }; + const identifiable1Updated = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', value: value }; + const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' }; + const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' }; + const identifiables = [identifiable1, identifiable2]; + + const fieldUpdates = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + }; + + const modDate = new Date(2010, 2, 11); + + beforeEach(() => { + const fieldStates = { + [identifiable1.uuid]: { editable: false, isNew: false }, + [identifiable2.uuid]: { editable: true, isNew: false }, + [identifiable3.uuid]: { editable: true, isNew: true }, + }; + + const objectEntry = { + fieldStates, fieldUpdates, lastModified: modDate + }; + store = new Store(undefined, undefined, undefined); + spyOn(store, 'dispatch'); + service = new ObjectUpdatesService(store); + + spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); + spyOn(service as any, 'saveFieldUpdate'); + }); + + describe('initialize', () => { + it('should dispatch an INITIALIZE action with the correct URL, initial identifiables and the last modified date', () => { + service.initialize(url, identifiables, modDate); + expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate)); + }); + }); + + describe('getFieldUpdates', () => { + it('should return the list of all fields, including their update if there is one', () => { + const result$ = service.getFieldUpdates(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + }; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('isEditable', () => { + it('should return false if this identifiable is currently not editable in the store', () => { + const result$ = service.isEditable(url, identifiable1.uuid); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + result$.subscribe((result) => { + expect(result).toEqual(false); + }); + }); + + it('should return true if this identifiable is currently editable in the store', () => { + const result$ = service.isEditable(url, identifiable2.uuid); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + result$.subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + + describe('saveAddFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.ADD', () => { + service.saveAddFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.ADD); + }); + }); + + describe('saveRemoveFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.REMOVE', () => { + service.saveRemoveFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.REMOVE); + }); + }); + + describe('saveChangeFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.UPDATE', () => { + service.saveChangeFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.UPDATE); + }); + }); + + describe('setEditableFieldUpdate', () => { + it('should dispatch a SetEditableFieldUpdateAction action with the correct URL, uuid and true when true was set', () => { + service.setEditableFieldUpdate(url, identifiable1.uuid, true); + expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, true)); + }); + + it('should dispatch an SetEditableFieldUpdateAction action with the correct URL, uuid and false when false was set', () => { + service.setEditableFieldUpdate(url, identifiable1.uuid, false); + expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, false)); + }); + }); + + describe('discardFieldUpdates', () => { + it('should dispatch a DiscardObjectUpdatesAction action with the correct URL and passed notification ', () => { + const undoNotification = new Notification('id', NotificationType.Info, 'undo'); + service.discardFieldUpdates(url, undoNotification); + expect(store.dispatch).toHaveBeenCalledWith(new DiscardObjectUpdatesAction(url, undoNotification)); + }); + }); + + describe('reinstateFieldUpdates', () => { + it('should dispatch a ReinstateObjectUpdatesAction action with the correct URL ', () => { + service.reinstateFieldUpdates(url); + expect(store.dispatch).toHaveBeenCalledWith(new ReinstateObjectUpdatesAction(url)); + }); + }); + + describe('removeSingleFieldUpdate', () => { + it('should dispatch a RemoveFieldUpdateAction action with the correct URL and uuid', () => { + service.removeSingleFieldUpdate(url, identifiable1.uuid); + expect(store.dispatch).toHaveBeenCalledWith(new RemoveFieldUpdateAction(url, identifiable1.uuid)); + }); + }); + + describe('getUpdatedFields', () => { + it('should return the list of all metadata fields with their new values', () => { + const result$ = service.getUpdatedFields(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = [identifiable1Updated, identifiable2, identifiable3]; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('hasUpdates', () => { + it('should return true when there are updates', () => { + const result$ = service.hasUpdates(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = true; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + describe('when updates are emtpy', () => { + beforeEach(() => { + (service as any).getObjectEntry.and.returnValue(observableOf({})) + }); + + it('should return false when there are no updates', () => { + const result$ = service.hasUpdates(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = false; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + }); + + describe('isReinstatable', () => { + + describe('when updates are not emtpy', () => { + beforeEach(() => { + spyOn(service, 'hasUpdates').and.returnValue(observableOf(true)); + }); + + it('should return true', () => { + const result$ = service.isReinstatable(url); + expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH); + + const expectedResult = true; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('when updates are emtpy', () => { + beforeEach(() => { + spyOn(service, 'hasUpdates').and.returnValue(observableOf(false)); + }); + + it('should return false', () => { + const result$ = service.isReinstatable(url); + expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH); + const expectedResult = false; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + }); + + describe('getLastModified', () => { + it('should return true when hasUpdates returns true', () => { + const result$ = service.getLastModified(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = modDate; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + +}); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 2f3e31eb7c..4b6c7def0d 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -29,24 +29,49 @@ function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector state[url]); } +/** + * Service that dispatches and reads from the ObjectUpdates' state in the store + */ @Injectable() export class ObjectUpdatesService { constructor(private store: Store) { } + /** + * Method to dispatch an InitializeFieldsAction to the store + * @param url The page's URL for which the changes are being mapped + * @param fields The initial fields for the page's object + * @param lastModified The date the object was last modified + */ initialize(url, fields: Identifiable[], lastModified: Date): void { this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); } + /** + * Method to dispatch an AddFieldUpdateAction to the store + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + * @param changeType The last type of change applied to this field + */ private saveFieldUpdate(url: string, field: Identifiable, changeType: FieldChangeType) { this.store.dispatch(new AddFieldUpdateAction(url, field, changeType)) } + /** + * Request the ObjectUpdatesEntry state for a specific URL + * @param url The URL to filter by + */ private getObjectEntry(url: string): Observable { return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url))); } + /** + * Method that combines the state's updates with the initial values (when there's no update) to create + * a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + */ getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { const objectUpdates = this.getObjectEntry(url); return objectUpdates.pipe(map((objectEntry) => { @@ -63,6 +88,11 @@ export class ObjectUpdatesService { })) } + /** + * Method to check if a specific field is currently editable in the store + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field + */ isEditable(url: string, uuid: string): Observable { const objectUpdates = this.getObjectEntry(url); return objectUpdates.pipe( @@ -72,34 +102,74 @@ export class ObjectUpdatesService { ) } + /** + * Calls the saveFieldUpdate method with FieldChangeType.ADD + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ saveAddFieldUpdate(url: string, field: Identifiable) { this.saveFieldUpdate(url, field, FieldChangeType.ADD); } + /** + * Calls the saveFieldUpdate method with FieldChangeType.REMOVE + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ saveRemoveFieldUpdate(url: string, field: Identifiable) { this.saveFieldUpdate(url, field, FieldChangeType.REMOVE); } + /** + * Calls the saveFieldUpdate method with FieldChangeType.UPDATE + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ saveChangeFieldUpdate(url: string, field: Identifiable) { this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); } + /** + * Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field that should be set + * @param editable The new value of editable in the store for this field + */ setEditableFieldUpdate(url: string, uuid: string, editable: boolean) { this.store.dispatch(new SetEditableFieldUpdateAction(url, uuid, editable)); } + /** + * Method to dispatch an DiscardObjectUpdatesAction to the store + * @param url The page's URL for which the changes should be discarded + * @param undoNotification The notification which is should possibly be canceled + */ discardFieldUpdates(url: string, undoNotification: INotification) { this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification)); } + /** + * Method to dispatch an ReinstateObjectUpdatesAction to the store + * @param url The page's URL for which the changes should be reinstated + */ reinstateFieldUpdates(url: string) { this.store.dispatch(new ReinstateObjectUpdatesAction(url)); } + /** + * Method to dispatch an RemoveFieldUpdateAction to the store + * @param url The page's URL for which the changes should be removed + */ removeSingleFieldUpdate(url: string, uuid) { this.store.dispatch(new RemoveFieldUpdateAction(url, uuid)); } + /** + * Method that combines the state's updates with the initial values (when there's no update) to create + * a list of updates fields + * @param url The URL of the page for which the updated fields should be requested + * @param initialFields The initial values of the fields + */ getUpdatedFields(url: string, initialFields: Identifiable[]): Observable { const objectUpdates = this.getObjectEntry(url); return objectUpdates.pipe(map((objectEntry) => { @@ -120,14 +190,26 @@ export class ObjectUpdatesService { })) } + /** + * Checks if the page currently has updates in the store or not + * @param url The page's url to check for in the store + */ 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) + /** + * Checks if the page currently is reinstatable in the store or not + * @param url The page's url to check for in the store + */ + isReinstatable(url: string): Observable { + return this.hasUpdates(url + OBJECT_UPDATES_TRASH_PATH) } + /** + * Request the current lastModified date stored for the updates in the store + * @param url The page's url to check for in the store + */ getLastModified(url: string): Observable { return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); } diff --git a/src/app/shared/input-suggestions/input-suggestions.component.html b/src/app/shared/input-suggestions/input-suggestions.component.html index 2ee80e576a..b620f4b79a 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.html +++ b/src/app/shared/input-suggestions/input-suggestions.component.html @@ -10,7 +10,7 @@ [ngModelOptions]="{standalone: true}" autocomplete="off"/>