59334: edit item metadata finished

This commit is contained in:
lotte
2019-02-13 11:39:32 +01:00
parent 714811dc07
commit 7eec961fa7
14 changed files with 233 additions and 98 deletions

View File

@@ -20,7 +20,8 @@ describe('MetadataSchemaFormComponent', () => {
/* tslint:disable:no-empty */ /* tslint:disable:no-empty */
const registryServiceStub = { const registryServiceStub = {
getActiveMetadataSchema: () => observableOf(undefined), getActiveMetadataSchema: () => observableOf(undefined),
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema) createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema),
cancelEditMetadataSchema: () => {}
}; };
const formBuilderServiceStub = { const formBuilderServiceStub = {
createFormGroup: () => { createFormGroup: () => {

View File

@@ -27,7 +27,8 @@ describe('MetadataFieldFormComponent', () => {
/* tslint:disable:no-empty */ /* tslint:disable:no-empty */
const registryServiceStub = { const registryServiceStub = {
getActiveMetadataField: () => observableOf(undefined), getActiveMetadataField: () => observableOf(undefined),
createOrUpdateMetadataField: (field: MetadataField) => observableOf(field) createOrUpdateMetadataField: (field: MetadataField) => observableOf(field),
cancelEditMetadataSchema: () => {},
}; };
const formBuilderServiceStub = { const formBuilderServiceStub = {
createFormGroup: () => { createFormGroup: () => {

View File

@@ -1,3 +1,4 @@
<script src="edit-in-place-field.component.ts"></script>
<div [ngClass]="{ <div [ngClass]="{
'table-warning': fieldUpdate.changeType === 0, 'table-warning': fieldUpdate.changeType === 0,
'table-danger': fieldUpdate.changeType === 2, 'table-danger': fieldUpdate.changeType === 2,
@@ -11,18 +12,18 @@
<div *ngIf="(editable | async)" class="field-container"> <div *ngIf="(editable | async)" class="field-container">
<ds-input-suggestions [suggestions]="(metadataFieldSuggestions | async)" <ds-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key" [(ngModel)]="metadata.key"
(submitSuggestion)="update()" (submitSuggestion)="update(suggestionControl.control)"
(clickSuggestion)="update()" (clickSuggestion)="update(suggestionControl.control)"
(typeSuggestion)="update()" (typeSuggestion)="update(suggestionControl.control)"
(findSuggestions)="findMetadataFieldSuggestions($event)" (findSuggestions)="findMetadataFieldSuggestions($event)"
[formControl]="formControl" #suggestionControl="ngModel"
ngDefaultControl [dsInListValidator]="metadataFields | async"
></ds-input-suggestions> ></ds-input-suggestions>
</div> </div>
<small class="text-danger" <small class="text-danger"
*ngIf="!(valid | async)">{{"item.edit.metadata.metadatafield.invalid" | translate}}</small> *ngIf="!(valid | async)">{{"item.edit.metadata.metadatafield.invalid" | translate}}</small>
</td> </td>
<td class="col-7"> <td class="col-6">
<div *ngIf="!(editable | async)"> <div *ngIf="!(editable | async)">
<span>{{metadata?.value}}</span> <span>{{metadata?.value}}</span>
</div> </div>
@@ -40,16 +41,20 @@
(onDebounce)="update()"/> (onDebounce)="update()"/>
</div> </div>
</td> </td>
<td class="col-1 text-center"> <td class="col-2 text-center">
<div> <div class="btn-group">
<i *ngIf="canSetEditable() | async" class="fas fa-edit fa-fw text-primary" <button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)" (click)="setEditable(true)" class="btn btn-light btn-sm">
(click)="setEditable(true)"></i> <i class="fas fa-edit fa-fw text-primary"></i>
<i *ngIf="canSetUneditable() | async" class="fas fa-check fa-fw text-success" </button>
(click)="setEditable(false)"></i> <button [disabled]="!(canSetUneditable() | async)" *ngIf="(editable | async)" (click)="setEditable(false)" class="btn btn-light btn-sm">
<i *ngIf="canRemove() | async" class="fas fa-trash-alt fa-fw text-danger" <i class="fas fa-check fa-fw text-success"></i>
(click)="remove()"></i> </button>
<i *ngIf="canUndo() | async" class="fas fa-undo-alt fa-fw text-warning" <button [disabled]="!(canRemove() | async)" (click)="remove()" class="btn btn-light btn-sm">
(click)="removeChangesFromField()"></i> <i class="fas fa-trash-alt fa-fw text-danger"></i>
</button>
<button [disabled]="!(canUndo() | async)" (click)="removeChangesFromField()" class="btn btn-light btn-sm">
<i class="fas fa-undo-alt fa-fw text-warning"></i>
</button>
</div> </div>
</td> </td>
</div> </div>

View File

@@ -16,6 +16,7 @@ import { InputSuggestion } from '../../../../shared/input-suggestions/input-sugg
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { MetadataSchema } from '../../../../core/metadata/metadataschema.model'; import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { TranslateModule } from '@ngx-translate/core';
let comp: EditInPlaceFieldComponent; let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture<EditInPlaceFieldComponent>; let fixture: ComponentFixture<EditInPlaceFieldComponent>;
@@ -58,20 +59,23 @@ describe('EditInPlaceFieldComponent', () => {
paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]);
metadataFieldService = jasmine.createSpyObj({ metadataFieldService = jasmine.createSpyObj({
queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)) queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)),
getAllMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields))
}); });
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{ {
saveChangeFieldUpdate: {}, saveChangeFieldUpdate: {},
saveRemoveFieldUpdate: {}, saveRemoveFieldUpdate: {},
setEditableFieldUpdate: {}, setEditableFieldUpdate: {},
setValidFieldUpdate: {},
removeSingleFieldUpdate: {}, removeSingleFieldUpdate: {},
isEditable: observableOf(false) // should always return something --> its in ngOnInit isEditable: observableOf(false), // should always return something --> its in ngOnInit
isValid: observableOf(true) // should always return something --> its in ngOnInit
} }
); );
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [FormsModule, SharedModule], imports: [FormsModule, SharedModule, TranslateModule.forRoot()],
declarations: [EditInPlaceFieldComponent], declarations: [EditInPlaceFieldComponent],
providers: [ providers: [
{ provide: RegistryService, useValue: metadataFieldService }, { provide: RegistryService, useValue: metadataFieldService },
@@ -170,13 +174,27 @@ describe('EditInPlaceFieldComponent', () => {
}); });
}); });
describe('remove', () => { describe('isValid is true', () => {
beforeEach(() => { beforeEach(() => {
comp.remove(); comp.valid = observableOf(true);
fixture.detectChanges();
}); });
it('the div should not contain an error message', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBe(0);
});
});
describe('isValid is false', () => {
beforeEach(() => {
comp.valid = observableOf(false);
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBeGreaterThan(0);
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(route, metadatum);
}); });
}); });
@@ -190,6 +208,16 @@ describe('EditInPlaceFieldComponent', () => {
}); });
}); });
describe('removeChangesFromField', () => {
beforeEach(() => {
comp.removeChangesFromField();
});
it('it should call removeChangesFromField on the objectUpdatesService with the correct route and uuid', () => {
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(route, metadatum.uuid);
});
});
describe('findMetadataFieldSuggestions', () => { describe('findMetadataFieldSuggestions', () => {
const query = 'query string'; const query = 'query string';
@@ -281,44 +309,49 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits true', () => { describe('when canSetEditable emits true', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(false);
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
fixture.detectChanges();
}); });
it('the div should contain a edit icon', () => { it('the div should have an enabled button with an edit icon', () => {
const editIcon = de.query(By.css('i.fa-edit')); const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled;
expect(editIcon).not.toBeNull(); expect(editIcon).toBe(false);
}); });
}); });
describe('when canSetEditable emits false', () => { describe('when canSetEditable emits false', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(false);
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false)); spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should not contain a edit icon', () => { it('the div should have a disabled button with an edit icon', () => {
const editIcon = de.query(By.css('i.fa-edit')); const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled;
expect(editIcon).toBeNull(); expect(editIcon).toBe(true);
}); });
}); });
describe('when canSetUneditable emits true', () => { describe('when canSetUneditable emits true', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(true);
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true)); spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should contain a check icon', () => { it('the div should have an enabled button with a check icon', () => {
const checkIcon = de.query(By.css('i.fa-check')); const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled;
expect(checkIcon).not.toBeNull(); expect(checkButtonAttrs).toBe(false);
}); });
}); });
describe('when canSetUneditable emits false', () => { describe('when canSetUneditable emits false', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(true);
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false)); spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should not contain a check icon', () => { it('the div should have a disabled button with a check icon', () => {
const checkIcon = de.query(By.css('i.fa-check')); const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled;
expect(checkIcon).toBeNull(); expect(checkButtonAttrs).toBe(true);
}); });
}); });
@@ -327,9 +360,9 @@ describe('EditInPlaceFieldComponent', () => {
spyOn(comp, 'canRemove').and.returnValue(observableOf(true)); spyOn(comp, 'canRemove').and.returnValue(observableOf(true));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should contain a trash icon', () => { it('the div should have an enabled button with a trash icon', () => {
const trashIcon = de.query(By.css('i.fa-trash-alt')); const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled;
expect(trashIcon).not.toBeNull(); expect(trashButtonAttrs).toBe(false);
}); });
}); });
@@ -338,9 +371,9 @@ describe('EditInPlaceFieldComponent', () => {
spyOn(comp, 'canRemove').and.returnValue(observableOf(false)); spyOn(comp, 'canRemove').and.returnValue(observableOf(false));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should not contain a trash icon', () => { it('the div should have a disabled button with a trash icon', () => {
const trashIcon = de.query(By.css('i.fa-trash-alt')); const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled;
expect(trashIcon).toBeNull(); expect(trashButtonAttrs).toBe(true);
}); });
}); });
@@ -349,9 +382,9 @@ describe('EditInPlaceFieldComponent', () => {
spyOn(comp, 'canUndo').and.returnValue(observableOf(true)); spyOn(comp, 'canUndo').and.returnValue(observableOf(true));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should contain a undo icon', () => { it('the div should have an enabled button with an undo icon', () => {
const undoIcon = de.query(By.css('i.fa-undo-alt')); const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled;
expect(undoIcon).not.toBeNull(); expect(undoIcon).toBe(false);
}); });
}); });
@@ -360,9 +393,9 @@ describe('EditInPlaceFieldComponent', () => {
spyOn(comp, 'canUndo').and.returnValue(observableOf(false)); spyOn(comp, 'canUndo').and.returnValue(observableOf(false));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should not contain a undo icon', () => { it('the div should have a disabled button with an undo icon', () => {
const undoIcon = de.query(By.css('i.fa-undo-alt')); const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled;
expect(undoIcon).toBeNull(); expect(undoIcon).toBe(true);
}); });
}); });

View File

@@ -1,19 +1,18 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core'; import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { isNotEmpty } from '../../../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { Metadatum } from '../../../../core/shared/metadatum.model'; import { Metadatum } from '../../../../core/shared/metadatum.model';
import { RegistryService } from '../../../../core/registry/registry.service'; import { RegistryService } from '../../../../core/registry/registry.service';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { map, take } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import { MetadataField } from '../../../../core/metadata/metadatafield.model'; import { MetadataField } from '../../../../core/metadata/metadatafield.model';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; 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 { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { inListValidator } from '../../../../shared/utils/validator.functions'; import { inListValidator } from '../../../../shared/utils/validator.functions';
import { getSucceededRemoteData } from '../../../../core/shared/operators'; import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { FormControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { FormControl } from '@angular/forms';
@Component({ @Component({
selector: 'ds-edit-in-place-field', selector: 'ds-edit-in-place-field',
@@ -51,7 +50,10 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
*/ */
metadataFieldSuggestions: BehaviorSubject<InputSuggestion[]> = new BehaviorSubject([]); metadataFieldSuggestions: BehaviorSubject<InputSuggestion[]> = new BehaviorSubject([]);
formControl: FormControl; /**
* List of strings with all metadata field keys available
*/
metadataFields: Observable<string[]>;
constructor( constructor(
private metadataFieldService: RegistryService, private metadataFieldService: RegistryService,
@@ -61,23 +63,21 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
/** /**
* Sets up an observable that keeps track of the current editable and valid state of this field * Sets up an observable that keeps track of the current editable and valid state of this field
* Also creates a form control object for the input suggestions
*/ */
ngOnInit(): void { ngOnInit(): void {
this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid); this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid);
this.valid = this.objectUpdatesService.isValid(this.route, this.metadata.uuid); this.valid = this.objectUpdatesService.isValid(this.route, this.metadata.uuid);
this.findMetadataFields().pipe(take(1)).subscribe((metadataFields: string[]) => { this.metadataFields = this.findMetadataFields()
const validator = inListValidator(metadataFields);
this.formControl = new FormControl('', validator);
});
} }
/** /**
* Sends a new change update for this field to the object updates service * Sends a new change update for this field to the object updates service
*/ */
update() { update(control?: FormControl) {
this.objectUpdatesService.saveChangeFieldUpdate(this.route, this.metadata); this.objectUpdatesService.saveChangeFieldUpdate(this.route, this.metadata);
this.objectUpdatesService.setValidFieldUpdate(this.route, this.metadata.uuid, this.formControl.valid); if (hasValue(control)) {
this.objectUpdatesService.setValidFieldUpdate(this.route, this.metadata.uuid, control.valid);
}
} }
/** /**
@@ -131,6 +131,9 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
); );
} }
/**
* Method to request all metadata fields and convert them to a list of strings
*/
findMetadataFields(): Observable<string[]> { findMetadataFields(): Observable<string[]> {
return this.metadataFieldService.getAllMetadataFields().pipe( return this.metadataFieldService.getAllMetadataFields().pipe(
getSucceededRemoteData(), getSucceededRemoteData(),

View File

@@ -26,9 +26,9 @@
<tbody> <tbody>
<tr class="d-flex"> <tr class="d-flex">
<th class="col-3">{{'item.edit.metadata.headers.field' | translate}}</th> <th class="col-3">{{'item.edit.metadata.headers.field' | translate}}</th>
<th class="col-7">{{'item.edit.metadata.headers.value' | translate}}</th> <th class="col-6">{{'item.edit.metadata.headers.value' | translate}}</th>
<th class="col-1 text-center">{{'item.edit.metadata.headers.language' | translate}}</th> <th class="col-1 text-center">{{'item.edit.metadata.headers.language' | translate}}</th>
<th class="col-1 text-center">{{'item.edit.metadata.headers.edit' | translate}}</th> <th class="col-2 text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
</tr> </tr>
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"> <tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate">
<ds-edit-in-place-field [fieldUpdate]="updateValue || {}" <ds-edit-in-place-field [fieldUpdate]="updateValue || {}"

View File

@@ -99,7 +99,8 @@ describe('ItemMetadataComponent', () => {
getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]), getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]),
getLastModified: observableOf(date), getLastModified: observableOf(date),
hasUpdates: observableOf(true), hasUpdates: observableOf(true),
isReinstatable: observableOf(false) // should always return something --> its in ngOnInit isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
isValidPage: observableOf(true)
} }
); );

View File

@@ -2,10 +2,10 @@ import { type } from '../../../shared/ngrx/type';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { Identifiable } from './object-updates.reducer'; import { Identifiable } from './object-updates.reducer';
import { INotification } from '../../../shared/notifications/models/notification.model'; import { INotification } from '../../../shared/notifications/models/notification.model';
/** /**
* The list of ObjectUpdatesAction type definitions * The list of ObjectUpdatesAction type definitions
*/ */
export const ObjectUpdatesActionTypes = { export const ObjectUpdatesActionTypes = {
INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'),
SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),

View File

@@ -6,7 +6,7 @@ import {
InitializeFieldsAction, InitializeFieldsAction,
ReinstateObjectUpdatesAction, ReinstateObjectUpdatesAction,
RemoveFieldUpdateAction, RemoveObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction,
SetEditableFieldUpdateAction SetEditableFieldUpdateAction, SetValidFieldUpdateAction
} from './object-updates.actions'; } from './object-updates.actions';
import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer';
@@ -54,22 +54,25 @@ describe('objectUpdatesReducer', () => {
fieldStates: { fieldStates: {
[identifiable1.uuid]: { [identifiable1.uuid]: {
editable: true, editable: true,
isNew: false isNew: false,
isValid: true
}, },
[identifiable2.uuid]: { [identifiable2.uuid]: {
editable: false, editable: false,
isNew: true isNew: true,
isValid: true
}, },
[identifiable3.uuid]: { [identifiable3.uuid]: {
editable: false, editable: false,
isNew: false isNew: false,
isValid: false
}, },
}, },
fieldUpdates: { fieldUpdates: {
[identifiable2.uuid]: { [identifiable2.uuid]: {
field: { field: {
uuid: identifiable2.uuid, uuid: identifiable2.uuid,
key: 'dc.title', key: 'dc.titl',
language: null, language: null,
value: 'New title' value: 'New title'
}, },
@@ -85,15 +88,18 @@ describe('objectUpdatesReducer', () => {
fieldStates: { fieldStates: {
[identifiable1.uuid]: { [identifiable1.uuid]: {
editable: true, editable: true,
isNew: false isNew: false,
isValid: true
}, },
[identifiable2.uuid]: { [identifiable2.uuid]: {
editable: false, editable: false,
isNew: true isNew: true,
isValid: true
}, },
[identifiable3.uuid]: { [identifiable3.uuid]: {
editable: false, editable: false,
isNew: false isNew: false,
isValid: true
}, },
}, },
lastModified: modDate lastModified: modDate
@@ -102,22 +108,25 @@ describe('objectUpdatesReducer', () => {
fieldStates: { fieldStates: {
[identifiable1.uuid]: { [identifiable1.uuid]: {
editable: true, editable: true,
isNew: false isNew: false,
isValid: true
}, },
[identifiable2.uuid]: { [identifiable2.uuid]: {
editable: false, editable: false,
isNew: true isNew: true,
isValid: true
}, },
[identifiable3.uuid]: { [identifiable3.uuid]: {
editable: false, editable: false,
isNew: false isNew: false,
isValid: false
}, },
}, },
fieldUpdates: { fieldUpdates: {
[identifiable2.uuid]: { [identifiable2.uuid]: {
field: { field: {
uuid: identifiable2.uuid, uuid: identifiable2.uuid,
key: 'dc.title', key: 'dc.titl',
language: null, language: null,
value: 'New title' value: 'New title'
}, },
@@ -194,11 +203,13 @@ describe('objectUpdatesReducer', () => {
fieldStates: { fieldStates: {
[identifiable1.uuid]: { [identifiable1.uuid]: {
editable: false, editable: false,
isNew: false isNew: false,
isValid: true
}, },
[identifiable3.uuid]: { [identifiable3.uuid]: {
editable: false, editable: false,
isNew: false isNew: false,
isValid: true
}, },
}, },
fieldUpdates: {}, fieldUpdates: {},
@@ -216,6 +227,13 @@ describe('objectUpdatesReducer', () => {
expect(newState[url].fieldStates[identifiable3.uuid].editable).toBeTruthy(); expect(newState[url].fieldStates[identifiable3.uuid].editable).toBeTruthy();
}); });
it('should set the given field\'s fieldStates when the SET_VALID_FIELD action is dispatched, based on the payload', () => {
const action = new SetValidFieldUpdateAction(url, identifiable3.uuid, false);
const newState = objectUpdatesReducer(testState, action);
expect(newState[url].fieldStates[identifiable3.uuid].isValid).toBeFalsy();
});
it('should add a given field\'s update to the state when the ADD_FIELD action is dispatched, based on the payload', () => { 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 action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE);

View File

@@ -32,9 +32,9 @@ describe('ObjectUpdatesService', () => {
beforeEach(() => { beforeEach(() => {
const fieldStates = { const fieldStates = {
[identifiable1.uuid]: { editable: false, isNew: false }, [identifiable1.uuid]: { editable: false, isNew: false, isValid: true },
[identifiable2.uuid]: { editable: true, isNew: false }, [identifiable2.uuid]: { editable: true, isNew: false, isValid: false },
[identifiable3.uuid]: { editable: true, isNew: true }, [identifiable3.uuid]: { editable: true, isNew: true, isValid: true },
}; };
const objectEntry = { const objectEntry = {
@@ -45,6 +45,9 @@ describe('ObjectUpdatesService', () => {
service = new ObjectUpdatesService(store); service = new ObjectUpdatesService(store);
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
return observableOf(fieldStates[uuid]);
});
spyOn(service as any, 'saveFieldUpdate'); spyOn(service as any, 'saveFieldUpdate');
}); });
@@ -75,7 +78,7 @@ describe('ObjectUpdatesService', () => {
describe('isEditable', () => { describe('isEditable', () => {
it('should return false if this identifiable is currently not editable in the store', () => { it('should return false if this identifiable is currently not editable in the store', () => {
const result$ = service.isEditable(url, identifiable1.uuid); const result$ = service.isEditable(url, identifiable1.uuid);
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid);
result$.subscribe((result) => { result$.subscribe((result) => {
expect(result).toEqual(false); expect(result).toEqual(false);
}); });
@@ -83,7 +86,25 @@ describe('ObjectUpdatesService', () => {
it('should return true if this identifiable is currently editable in the store', () => { it('should return true if this identifiable is currently editable in the store', () => {
const result$ = service.isEditable(url, identifiable2.uuid); const result$ = service.isEditable(url, identifiable2.uuid);
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid);
result$.subscribe((result) => {
expect(result).toEqual(true);
});
});
});
describe('isValid', () => {
it('should return false if this identifiable is currently not valid in the store', () => {
const result$ = service.isValid(url, identifiable2.uuid);
expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid);
result$.subscribe((result) => {
expect(result).toEqual(false);
});
});
it('should return true if this identifiable is currently valid in the store', () => {
const result$ = service.isValid(url, identifiable1.uuid);
expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid);
result$.subscribe((result) => { result$.subscribe((result) => {
expect(result).toEqual(true); expect(result).toEqual(true);
}); });

View File

@@ -18,7 +18,7 @@ import {
RemoveFieldUpdateAction, RemoveFieldUpdateAction,
SetEditableFieldUpdateAction, SetValidFieldUpdateAction SetEditableFieldUpdateAction, SetValidFieldUpdateAction
} from './object-updates.actions'; } from './object-updates.actions';
import { filter, map } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { INotification } from '../../../shared/notifications/models/notification.model'; import { INotification } from '../../../shared/notifications/models/notification.model';
@@ -30,6 +30,10 @@ function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector<Co
return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]); return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]);
} }
function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): MemoizedSelector<CoreState, FieldState> {
return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]);
}
/** /**
* Service that dispatches and reads from the ObjectUpdates' state in the store * Service that dispatches and reads from the ObjectUpdates' state in the store
*/ */
@@ -67,6 +71,15 @@ export class ObjectUpdatesService {
return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url))); return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url)));
} }
/**
* Request the getFieldState state for a specific URL and UUID
* @param url The URL to filter by
* @param uuid The field's UUID to filter by
*/
private getFieldState(url: string, uuid: string): Observable<FieldState> {
return this.store.pipe(select(filterByUrlAndUUIDFieldStateSelector(url, uuid)));
}
/** /**
* Method that combines the state's updates with the initial values (when there's no update) to create * Method that combines the state's updates with the initial values (when there's no update) to create
* a FieldUpdates object * a FieldUpdates object
@@ -95,11 +108,11 @@ export class ObjectUpdatesService {
* @param uuid The UUID of the field * @param uuid The UUID of the field
*/ */
isEditable(url: string, uuid: string): Observable<boolean> { isEditable(url: string, uuid: string): Observable<boolean> {
const objectUpdates = this.getObjectEntry(url); const fieldState$ = this.getFieldState(url, uuid);
return objectUpdates.pipe( return fieldState$.pipe(
filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])), filter((fieldState) => hasValue(fieldState)),
map((objectEntry) => objectEntry.fieldStates[uuid].editable map((fieldState) => fieldState.editable),
) distinctUntilChanged()
) )
} }
@@ -109,11 +122,11 @@ export class ObjectUpdatesService {
* @param uuid The UUID of the field * @param uuid The UUID of the field
*/ */
isValid(url: string, uuid: string): Observable<boolean> { isValid(url: string, uuid: string): Observable<boolean> {
const objectUpdates = this.getObjectEntry(url); const fieldState$ = this.getFieldState(url, uuid);
return objectUpdates.pipe( return fieldState$.pipe(
filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])), filter((fieldState) => hasValue(fieldState)),
map((objectEntry) => objectEntry.fieldStates[uuid].isValid map((fieldState) => fieldState.isValid),
) distinctUntilChanged()
) )
} }
@@ -126,7 +139,8 @@ export class ObjectUpdatesService {
return objectUpdates.pipe( return objectUpdates.pipe(
map((entry: ObjectUpdatesEntry) => { map((entry: ObjectUpdatesEntry) => {
return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0 return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0
}) }),
distinctUntilChanged()
) )
} }

View File

@@ -92,6 +92,7 @@ import { EditComColPageComponent } from './comcol-forms/edit-comcol-page/edit-co
import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component';
import { LangSwitchComponent } from './lang-switch/lang-switch.component'; import { LangSwitchComponent } from './lang-switch/lang-switch.component';
import { ObjectValuesPipe } from './utils/object-values-pipe'; import { ObjectValuesPipe } from './utils/object-values-pipe';
import { InListValidator } from './utils/in-list-validator.directive';
const MODULES = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -197,7 +198,8 @@ const DIRECTIVES = [
VarDirective, VarDirective,
DragClickDirective, DragClickDirective,
DebounceDirective, DebounceDirective,
ClickOutsideDirective ClickOutsideDirective,
InListValidator
]; ];
@NgModule({ @NgModule({

View File

@@ -0,0 +1,32 @@
import { Directive, Input } from '@angular/core';
import { FormControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
import { inListValidator } from './validator.functions';
/**
* Directive for validating if a ngModel value is in a given list
*/
@Directive({
selector: '[ngModel][dsInListValidator]',
// We add our directive to the list of existing validators
providers: [
{ provide: NG_VALIDATORS, useExisting: InListValidator, multi: true }
]
})
export class InListValidator implements Validator {
/**
* The list to look in
*/
@Input()
dsInListValidator: string[];
/**
* The function that checks if the form control's value is currently valid
* @param c The FormControl
*/
validate(c: FormControl): ValidationErrors | null {
if (this.dsInListValidator !== null) {
return inListValidator(this.dsInListValidator)(c);
}
return null;
}
}

View File

@@ -1,7 +1,11 @@
import { AbstractControl, ValidatorFn } from '@angular/forms'; import { AbstractControl, ValidatorFn } from '@angular/forms';
/**
* Returns a validator function to check if the control's value is in a given list
* @param list The list to look in
*/
export function inListValidator(list: string[]): ValidatorFn { export function inListValidator(list: string[]): ValidatorFn {
return (control: AbstractControl): {[key: string]: any} | null => { return (control: AbstractControl): {[key: string]: any} | null => {
const contains = list.indexOf(control.value) > 0; const contains = list.indexOf(control.value) > -1;
return contains ? null : {inList: {value: control.value}} }; return contains ? null : {inList: {value: control.value}} };
} }