finished tests for item metadata updates

This commit is contained in:
lotte
2019-02-11 15:45:41 +01:00
parent ace523ed14
commit 643c0d6e1a
16 changed files with 1037 additions and 105 deletions

View File

@@ -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';

View File

@@ -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|)';

View File

@@ -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',

View File

@@ -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',

View File

@@ -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<Item>) => {

View File

@@ -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<T extends CacheableObject> {
protected abstract requestService: RequestService;
@@ -228,4 +229,12 @@ export abstract class DataService<T extends CacheableObject> {
);
}
/**
* 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);
}
}

View File

@@ -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

View File

@@ -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<any>;
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<ObjectUpdatesAction>();
});
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))
);
});
});
});
});
});

View File

@@ -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<ObjectUpdatesAction>
} = {};
/**
* 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) {
}
}

View File

@@ -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();
});
});

View File

@@ -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 = {};

View File

@@ -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<CoreState>;
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<CoreState>(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);
});
});
});
});

View File

@@ -29,24 +29,49 @@ function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector<Co
return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]);
}
/**
* Service that dispatches and reads from the ObjectUpdates' state in the store
*/
@Injectable()
export class ObjectUpdatesService {
constructor(private store: Store<CoreState>) {
}
/**
* 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<ObjectUpdatesEntry> {
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<FieldUpdates> {
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<boolean> {
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<Identifiable[]> {
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<boolean> {
return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates)));
}
isReinstatable(route: string): Observable<boolean> {
return this.hasUpdates(route + OBJECT_UPDATES_TRASH_PATH)
/**
* 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<boolean> {
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<Date> {
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
}

View File

@@ -10,7 +10,7 @@
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<div>
<div class="dropdown-list">
<div *ngFor="let suggestionOption of suggestions">
<a href="#" class="d-block dropdown-item" (click)="onClickSuggestion(suggestionOption.value)" #suggestion>
<span [innerHTML]="suggestionOption.displayValue"></span>

View File

@@ -77,7 +77,7 @@ describe('InputSuggestionsComponent', () => {
});
it('should put the focus on the last element ', () => {
const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
const lastLink = de.query(By.css('.dropdown-list > div:last-child a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(lastLink.nativeElement);
});
@@ -103,7 +103,7 @@ describe('InputSuggestionsComponent', () => {
});
it('should put the focus on the first element ', () => {
const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
const firstLink = de.query(By.css('.dropdown-list > div:first-child a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(firstLink.nativeElement);
});
@@ -117,7 +117,7 @@ describe('InputSuggestionsComponent', () => {
});
it('should put the focus on the second element', () => {
const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a'));
const secondLink = de.query(By.css('.dropdown-list > div:nth-child(2) a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(secondLink.nativeElement);
});
@@ -126,7 +126,7 @@ describe('InputSuggestionsComponent', () => {
describe('when the first element is in focus', () => {
beforeEach(() => {
const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
const firstLink = de.query(By.css('.dropdown-list > div:first-child a'));
firstLink.nativeElement.focus();
comp.selectedIndex = 0;
fixture.detectChanges();
@@ -140,7 +140,7 @@ describe('InputSuggestionsComponent', () => {
});
it('should put the focus on the last element ', () => {
const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
const lastLink = de.query(By.css('.dropdown-list > div:last-child a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(lastLink.nativeElement);
});
@@ -153,7 +153,7 @@ describe('InputSuggestionsComponent', () => {
});
it('should put the focus on the second element ', () => {
const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a'));
const secondLink = de.query(By.css('.dropdown-list > div:nth-child(2) a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(secondLink.nativeElement);
});
@@ -162,7 +162,7 @@ describe('InputSuggestionsComponent', () => {
describe('when the last element is in focus', () => {
beforeEach(() => {
const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
const lastLink = de.query(By.css('.dropdown-list > div:last-child a'));
lastLink.nativeElement.focus();
comp.selectedIndex = suggestions.length - 1;
fixture.detectChanges();
@@ -176,7 +176,7 @@ describe('InputSuggestionsComponent', () => {
});
it('should put the focus on the second last element ', () => {
const secondLastLink = de.query(By.css('.list-unstyled > li:nth-last-child(2) a'));
const secondLastLink = de.query(By.css('.dropdown-list > div:nth-last-child(2) a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(secondLastLink.nativeElement);
});
@@ -189,7 +189,7 @@ describe('InputSuggestionsComponent', () => {
});
it('should put the focus on the first element ', () => {
const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
const firstLink = de.query(By.css('.dropdown-list > div:first-child a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(firstLink.nativeElement);
});
@@ -294,7 +294,7 @@ describe('InputSuggestionsComponent', () => {
const clickedIndex = 0;
beforeEach(() => {
spyOn(comp, 'onClickSuggestion');
const clickedLink = de.query(By.css('.list-unstyled > li:nth-child(' + (clickedIndex + 1) + ') a'));
const clickedLink = de.query(By.css('.dropdown-list > div:nth-child(' + (clickedIndex + 1) + ') a'));
clickedLink.triggerEventHandler('click', {} );
fixture.detectChanges();
});

View File

@@ -207,9 +207,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor {
}
registerOnTouched(fn: any): void {
/* no implementation */
}
setDisabledState(isDisabled: boolean): void {
/* no implementation */
}
writeValue(value: any): void {