diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index 02cf168387..42b6d1f133 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -20,7 +20,8 @@ describe('MetadataSchemaFormComponent', () => { /* tslint:disable:no-empty */ const registryServiceStub = { getActiveMetadataSchema: () => observableOf(undefined), - createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema) + createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema), + cancelEditMetadataSchema: () => {} }; const formBuilderServiceStub = { createFormGroup: () => { diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 25502a27c8..4364b0234a 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -27,7 +27,8 @@ describe('MetadataFieldFormComponent', () => { /* tslint:disable:no-empty */ const registryServiceStub = { getActiveMetadataField: () => observableOf(undefined), - createOrUpdateMetadataField: (field: MetadataField) => observableOf(field) + createOrUpdateMetadataField: (field: MetadataField) => observableOf(field), + cancelEditMetadataSchema: () => {}, }; const formBuilderServiceStub = { createFormGroup: () => { diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html index 51f4c650af..e4fdc60a1e 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -1,3 +1,4 @@ +
{{"item.edit.metadata.metadatafield.invalid" | translate}} - +
{{metadata?.value}}
@@ -40,16 +41,20 @@ (onDebounce)="update()"/> - -
- - - - + +
+ + + +
\ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts index aad94931dd..927a4a96b1 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -16,6 +16,7 @@ import { InputSuggestion } from '../../../../shared/input-suggestions/input-sugg import { TestScheduler } from 'rxjs/testing'; import { MetadataSchema } from '../../../../core/metadata/metadataschema.model'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { TranslateModule } from '@ngx-translate/core'; let comp: EditInPlaceFieldComponent; let fixture: ComponentFixture; @@ -58,20 +59,23 @@ describe('EditInPlaceFieldComponent', () => { paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); 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', { saveChangeFieldUpdate: {}, saveRemoveFieldUpdate: {}, setEditableFieldUpdate: {}, + setValidFieldUpdate: {}, 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({ - imports: [FormsModule, SharedModule], + imports: [FormsModule, SharedModule, TranslateModule.forRoot()], declarations: [EditInPlaceFieldComponent], providers: [ { provide: RegistryService, useValue: metadataFieldService }, @@ -170,13 +174,27 @@ describe('EditInPlaceFieldComponent', () => { }); }); - describe('remove', () => { + describe('isValid is true', () => { 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', () => { const query = 'query string'; @@ -281,44 +309,49 @@ describe('EditInPlaceFieldComponent', () => { describe('when canSetEditable emits true', () => { beforeEach(() => { + comp.editable = observableOf(false); spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); + fixture.detectChanges(); }); - it('the div should contain a edit icon', () => { - const editIcon = de.query(By.css('i.fa-edit')); - expect(editIcon).not.toBeNull(); + it('the div should have an enabled button with an edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; + expect(editIcon).toBe(false); }); }); describe('when canSetEditable emits false', () => { beforeEach(() => { + comp.editable = observableOf(false); 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(); + it('the div should have a disabled button with an edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; + expect(editIcon).toBe(true); }); }); describe('when canSetUneditable emits true', () => { beforeEach(() => { + comp.editable = observableOf(true); 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(); + it('the div should have an enabled button with a check icon', () => { + const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; + expect(checkButtonAttrs).toBe(false); }); }); describe('when canSetUneditable emits false', () => { beforeEach(() => { + comp.editable = observableOf(true); 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(); + it('the div should have a disabled button with a check icon', () => { + const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; + expect(checkButtonAttrs).toBe(true); }); }); @@ -327,9 +360,9 @@ describe('EditInPlaceFieldComponent', () => { 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(); + it('the div should have an enabled button with a trash icon', () => { + const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; + expect(trashButtonAttrs).toBe(false); }); }); @@ -338,9 +371,9 @@ describe('EditInPlaceFieldComponent', () => { 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(); + it('the div should have a disabled button with a trash icon', () => { + const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; + expect(trashButtonAttrs).toBe(true); }); }); @@ -349,9 +382,9 @@ describe('EditInPlaceFieldComponent', () => { 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(); + it('the div should have an enabled button with an undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; + expect(undoIcon).toBe(false); }); }); @@ -360,9 +393,9 @@ describe('EditInPlaceFieldComponent', () => { 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(); + it('the div should have a disabled button with an undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; + expect(undoIcon).toBe(true); }); }); diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts index b85b558cfd..d0b35b7a82 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts @@ -1,19 +1,18 @@ 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 { RegistryService } from '../../../../core/registry/registry.service'; import { cloneDeep } from 'lodash'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, Observable, of as observableOf } 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'; import { inListValidator } from '../../../../shared/utils/validator.functions'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; -import { FormControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { FormControl } from '@angular/forms'; @Component({ selector: 'ds-edit-in-place-field', @@ -51,7 +50,10 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { */ metadataFieldSuggestions: BehaviorSubject = new BehaviorSubject([]); - formControl: FormControl; + /** + * List of strings with all metadata field keys available + */ + metadataFields: Observable; constructor( 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 - * Also creates a form control object for the input suggestions */ ngOnInit(): void { this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid); this.valid = this.objectUpdatesService.isValid(this.route, this.metadata.uuid); - this.findMetadataFields().pipe(take(1)).subscribe((metadataFields: string[]) => { - const validator = inListValidator(metadataFields); - this.formControl = new FormControl('', validator); - }); + this.metadataFields = this.findMetadataFields() } /** * 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.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 { return this.metadataFieldService.getAllMetadataFields().pipe( getSucceededRemoteData(), diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html index bc8e52d48b..36d18372bd 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -26,9 +26,9 @@ {{'item.edit.metadata.headers.field' | translate}} - {{'item.edit.metadata.headers.value' | translate}} + {{'item.edit.metadata.headers.value' | translate}} {{'item.edit.metadata.headers.language' | translate}} - {{'item.edit.metadata.headers.edit' | translate}} + {{'item.edit.metadata.headers.edit' | translate}} { getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]), getLastModified: observableOf(date), 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) } ); diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index c1b35d07b4..7381188892 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -2,10 +2,10 @@ 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'), SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index 377b3ab1b4..f5698b9b78 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -6,7 +6,7 @@ import { InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction, - SetEditableFieldUpdateAction + SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; @@ -54,22 +54,25 @@ describe('objectUpdatesReducer', () => { fieldStates: { [identifiable1.uuid]: { editable: true, - isNew: false + isNew: false, + isValid: true }, [identifiable2.uuid]: { editable: false, - isNew: true + isNew: true, + isValid: true }, [identifiable3.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: false }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.title', + key: 'dc.titl', language: null, value: 'New title' }, @@ -85,15 +88,18 @@ describe('objectUpdatesReducer', () => { fieldStates: { [identifiable1.uuid]: { editable: true, - isNew: false + isNew: false, + isValid: true }, [identifiable2.uuid]: { editable: false, - isNew: true + isNew: true, + isValid: true }, [identifiable3.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: true }, }, lastModified: modDate @@ -102,22 +108,25 @@ describe('objectUpdatesReducer', () => { fieldStates: { [identifiable1.uuid]: { editable: true, - isNew: false + isNew: false, + isValid: true }, [identifiable2.uuid]: { editable: false, - isNew: true + isNew: true, + isValid: true }, [identifiable3.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: false }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.title', + key: 'dc.titl', language: null, value: 'New title' }, @@ -194,11 +203,13 @@ describe('objectUpdatesReducer', () => { fieldStates: { [identifiable1.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: true }, [identifiable3.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: true }, }, fieldUpdates: {}, @@ -216,6 +227,13 @@ describe('objectUpdatesReducer', () => { 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', () => { const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 4f0393c641..e9fc4652b0 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -32,9 +32,9 @@ describe('ObjectUpdatesService', () => { beforeEach(() => { const fieldStates = { - [identifiable1.uuid]: { editable: false, isNew: false }, - [identifiable2.uuid]: { editable: true, isNew: false }, - [identifiable3.uuid]: { editable: true, isNew: true }, + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, }; const objectEntry = { @@ -45,6 +45,9 @@ describe('ObjectUpdatesService', () => { service = new ObjectUpdatesService(store); 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'); }); @@ -75,7 +78,7 @@ describe('ObjectUpdatesService', () => { 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); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); result$.subscribe((result) => { expect(result).toEqual(false); }); @@ -83,7 +86,25 @@ describe('ObjectUpdatesService', () => { 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); + 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) => { expect(result).toEqual(true); }); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 6136d31ac0..85e17b5b2f 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -18,7 +18,7 @@ import { RemoveFieldUpdateAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } 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 { INotification } from '../../../shared/notifications/models/notification.model'; @@ -30,6 +30,10 @@ function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector state[url]); } +function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): MemoizedSelector { + return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); +} + /** * 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))); } + /** + * 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 { + 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 * a FieldUpdates object @@ -95,11 +108,11 @@ export class ObjectUpdatesService { * @param uuid The UUID of the field */ isEditable(url: string, uuid: string): Observable { - const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe( - filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])), - map((objectEntry) => objectEntry.fieldStates[uuid].editable - ) + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.editable), + distinctUntilChanged() ) } @@ -109,11 +122,11 @@ export class ObjectUpdatesService { * @param uuid The UUID of the field */ isValid(url: string, uuid: string): Observable { - const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe( - filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])), - map((objectEntry) => objectEntry.fieldStates[uuid].isValid - ) + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.isValid), + distinctUntilChanged() ) } @@ -126,7 +139,8 @@ export class ObjectUpdatesService { return objectUpdates.pipe( map((entry: ObjectUpdatesEntry) => { return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0 - }) + }), + distinctUntilChanged() ) } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index c3e7051304..6bdeabec43 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -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 { LangSwitchComponent } from './lang-switch/lang-switch.component'; import { ObjectValuesPipe } from './utils/object-values-pipe'; +import { InListValidator } from './utils/in-list-validator.directive'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -197,7 +198,8 @@ const DIRECTIVES = [ VarDirective, DragClickDirective, DebounceDirective, - ClickOutsideDirective + ClickOutsideDirective, + InListValidator ]; @NgModule({ diff --git a/src/app/shared/utils/in-list-validator.directive.ts b/src/app/shared/utils/in-list-validator.directive.ts new file mode 100644 index 0000000000..621ae93b83 --- /dev/null +++ b/src/app/shared/utils/in-list-validator.directive.ts @@ -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; + } +} diff --git a/src/app/shared/utils/validator.functions.ts b/src/app/shared/utils/validator.functions.ts index 55fe498747..3ad660731a 100644 --- a/src/app/shared/utils/validator.functions.ts +++ b/src/app/shared/utils/validator.functions.ts @@ -1,7 +1,11 @@ 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 { 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}} }; }