Skip to content
Snippets Groups Projects
Commit 714811dc authored by lotte's avatar lotte
Browse files

59334: added validation

parent 45c699e4
No related branches found
No related tags found
No related merge requests found
Showing
with 226 additions and 52 deletions
......@@ -233,6 +233,9 @@
"language": "Lang",
"edit": "Edit"
},
"metadatafield": {
"invalid": "Please choose a valid metadata field"
},
"notifications": {
"outdated": {
"title": "Changed outdated",
......@@ -241,6 +244,10 @@
"discarded": {
"title": "Changed discarded",
"content": "Your changes were discarded. To reinstate your changes click the 'Undo' button"
},
"invalid": {
"title": "Metadata invalid",
"content": "Please make sure all fields are valid"
}
}
}
......
......@@ -13,10 +13,14 @@
[(ngModel)]="metadata.key"
(submitSuggestion)="update()"
(clickSuggestion)="update()"
(typeSuggestion)="update()"
(findSuggestions)="findMetadataFieldSuggestions($event)"
[formControl]="formControl"
ngDefaultControl
></ds-input-suggestions>
</div>
<small class="text-danger"
*ngIf="!(valid | async)">{{"item.edit.metadata.metadatafield.invalid" | translate}}</small>
</td>
<td class="col-7">
<div *ngIf="!(editable | async)">
......@@ -38,10 +42,14 @@
</td>
<td class="col-1 text-center">
<div>
<i *ngIf="canSetEditable() | async" class="fas fa-edit fa-fw text-primary" (click)="setEditable(true)"></i>
<i *ngIf="canSetUneditable() | async" class="fas fa-check fa-fw text-success" (click)="setEditable(false)"></i>
<i *ngIf="canRemove() | async" class="fas fa-trash-alt fa-fw text-danger" (click)="remove()"></i>
<i *ngIf="canUndo() | async" class="fas fa-undo-alt fa-fw text-warning" (click)="removeChangesFromField()"></i>
<i *ngIf="canSetEditable() | async" class="fas fa-edit fa-fw text-primary"
(click)="setEditable(true)"></i>
<i *ngIf="canSetUneditable() | async" class="fas fa-check fa-fw text-success"
(click)="setEditable(false)"></i>
<i *ngIf="canRemove() | async" class="fas fa-trash-alt fa-fw text-danger"
(click)="remove()"></i>
<i *ngIf="canUndo() | async" class="fas fa-undo-alt fa-fw text-warning"
(click)="removeChangesFromField()"></i>
</div>
</td>
</div>
\ No newline at end of file
@import '../../../../../styles/variables.scss';
......@@ -11,6 +11,9 @@ import { FieldChangeType } from '../../../../core/data/object-updates/object-upd
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';
@Component({
selector: 'ds-edit-in-place-field',
......@@ -21,7 +24,6 @@ import { ObjectUpdatesService } from '../../../../core/data/object-updates/objec
* Component that displays a single metadatum of an item on the edit page
*/
export class EditInPlaceFieldComponent implements OnInit, OnChanges {
/**
* The current field, value and state of the metadatum
*/
......@@ -39,22 +41,43 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
*/
editable: Observable<boolean>;
/**
* Emits whether or not this field is currently valid
*/
valid: Observable<boolean>;
/**
* The current suggestions for the metadatafield when editing
*/
metadataFieldSuggestions: BehaviorSubject<InputSuggestion[]> = new BehaviorSubject([]);
formControl: FormControl;
constructor(
private metadataFieldService: RegistryService,
private objectUpdatesService: ObjectUpdatesService,
) {
}
/**
* 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);
});
}
/**
* Sends a new change update for this field to the object updates service
*/
update() {
this.objectUpdatesService.saveChangeFieldUpdate(this.route, this.metadata);
this.objectUpdatesService.setValidFieldUpdate(this.route, this.metadata.uuid, this.formControl.valid);
}
/**
......@@ -79,13 +102,6 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
this.objectUpdatesService.removeSingleFieldUpdate(this.route, this.metadata.uuid);
}
/**
* Sets up an observable that keeps track of the current editable state of this field
*/
ngOnInit(): void {
this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid);
}
/**
* Sets the current metadatafield based on the fieldUpdate input field
*/
......@@ -115,6 +131,13 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
);
}
findMetadataFields(): Observable<string[]> {
return this.metadataFieldService.getAllMetadataFields().pipe(
getSucceededRemoteData(),
take(1),
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
}
/**
* Check if a user should be allowed to edit this field
* @return an observable that emits true when the user should be able to edit this field and false when they should not
......
@import '../../../../styles/variables.scss';
.button-row .btn {
min-width: $button-min-width;
}
\ No newline at end of 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, tap } from 'rxjs/operators';
import { first, map, 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';
......@@ -20,6 +20,7 @@ import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'ds-item-metadata',
styleUrls: ['./item-metadata.component.scss'],
templateUrl: './item-metadata.component.html',
})
/**
......@@ -114,22 +115,30 @@ export class ItemMetadataComponent implements OnInit {
* Makes sure the new version of the item is rendered on the page
*/
submit() {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.route, this.item.metadata) as Observable<Metadatum[]>;
metadata$.pipe(
first(),
switchMap((metadata: Metadatum[]) => {
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata });
return this.itemService.update(updatedItem);
}),
tap(() => this.itemService.commitUpdates()),
getSucceededRemoteData()
).subscribe(
(rd: RemoteData<Item>) => {
this.item = rd.payload;
this.initializeOriginalFields();
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata);
}
)
this.isValid().pipe(first()).subscribe((isValid) => {
if (isValid) {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.route, this.item.metadata) as Observable<Metadatum[]>;
metadata$.pipe(
first(),
switchMap((metadata: Metadatum[]) => {
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata });
return this.itemService.update(updatedItem);
}),
tap(() => this.itemService.commitUpdates()),
getSucceededRemoteData()
).subscribe(
(rd: RemoteData<Item>) => {
this.item = rd.payload;
this.initializeOriginalFields();
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata);
}
)
} else {
const title = this.translateService.instant('item.edit.metadata.notifications.invalid.title');
const content = this.translateService.instant('item.edit.metadata.notifications.invalid.content');
this.notificationsService.error(title, content);
}
});
}
/**
......@@ -163,4 +172,8 @@ export class ItemMetadataComponent implements OnInit {
}
);
}
private isValid() {
return this.objectUpdatesService.isValidPage(this.route);
}
}
......@@ -38,7 +38,7 @@ const ITEM_EDIT_PATH = ':id/edit';
{
path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
canActivate: [AuthenticatedGuard]
// canActivate: [AuthenticatedGuard]
}
])
],
......
import {
ActionReducerMap,
createFeatureSelector,
createSelector,
MemoizedSelector
} from '@ngrx/store';
import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer';
......@@ -14,8 +12,6 @@ import {
objectUpdatesReducer,
ObjectUpdatesState
} from './data/object-updates/object-updates.reducer';
import { hasValue } from '../shared/empty.util';
import { AppState } from '../app.reducer';
export interface CoreState {
'cache/object': ObjectCacheState,
......@@ -35,4 +31,4 @@ export const coreReducers: ActionReducerMap<CoreState> = {
'auth': authReducer,
};
export const coreSelector = createFeatureSelector<CoreState>('core');
\ No newline at end of file
export const coreSelector = createFeatureSelector<CoreState>('core');
......@@ -9,6 +9,7 @@ import { INotification } from '../../../shared/notifications/models/notification
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'),
SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'),
ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
......@@ -109,6 +110,33 @@ export class SetEditableFieldUpdateAction implements Action {
}
}
/**
* An ngrx action to set the isValid state of an existing field in the ObjectUpdates state for a certain page url
*/
export class SetValidFieldUpdateAction implements Action {
type = ObjectUpdatesActionTypes.SET_VALID_FIELD;
payload: {
url: string,
uuid: string,
isValid: boolean,
};
/**
* Create a new SetEditableFieldUpdateAction
*
* @param url
* the unique url of the page
* @param fieldUUID The UUID of the field of which
* @param isValid The new isValid value for the field
*/
constructor(
url: string,
fieldUUID: string,
isValid: boolean) {
this.payload = { url, uuid: fieldUUID, isValid };
}
}
/**
* An ngrx action to discard all existing updates in the ObjectUpdates state for a certain page url
*/
......
......@@ -7,7 +7,7 @@ import {
ObjectUpdatesActionTypes,
ReinstateObjectUpdatesAction,
RemoveFieldUpdateAction,
RemoveObjectUpdatesAction, SetEditableFieldUpdateAction
RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction
} from './object-updates.actions';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
......@@ -15,7 +15,8 @@ export const OBJECT_UPDATES_TRASH_PATH = '/trash';
export interface FieldState {
editable: boolean,
isNew: boolean
isNew: boolean,
isValid: boolean
}
export interface FieldStates {
......@@ -46,8 +47,8 @@ export interface ObjectUpdatesState {
[url: string]: ObjectUpdatesEntry;
}
const initialFieldState = { editable: false, isNew: false };
const initialNewFieldState = { editable: true, isNew: true };
const initialFieldState = { editable: false, isNew: false, isValid: true };
const initialNewFieldState = { editable: true, isNew: true, isValid: true };
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
......@@ -80,6 +81,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: {
return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction);
}
case ObjectUpdatesActionTypes.SET_VALID_FIELD: {
return setValidFieldUpdate(state, action as SetValidFieldUpdateAction);
}
default: {
return state;
}
......@@ -147,8 +151,8 @@ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
Object.keys(pageState.fieldStates).forEach((uuid: string) => {
const fieldState: FieldState = pageState.fieldStates[uuid];
if (!fieldState.isNew) {
/* After discarding we don't want the reset fields to stay editable */
newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false });
/* After discarding we don't want the reset fields to stay editable or invalid */
newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false, isValid: true });
}
});
......@@ -215,7 +219,7 @@ function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) {
/* If this field was added, just throw it away */
delete newFieldStates[uuid];
} else {
newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false });
newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false, isValid: true });
}
}
newPageState = Object.assign({}, state[url], {
......@@ -243,7 +247,7 @@ function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType)
}
/**
* Set the state of a specific action's url and uuid to false or true
* Set the editable 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
*/
......@@ -264,6 +268,28 @@ function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction
return Object.assign({}, state, { [url]: newPageState });
}
/**
* Set the isValid 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 setValidFieldUpdate(state: any, action: SetValidFieldUpdateAction) {
const url: string = action.payload.url;
const uuid: string = action.payload.uuid;
const isValid: boolean = action.payload.isValid;
const pageState: ObjectUpdatesEntry = state[url];
const fieldState = pageState.fieldStates[uuid];
const newFieldState = Object.assign({}, fieldState, { isValid });
const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState });
const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates });
return Object.assign({}, state, { [url]: newPageState });
}
/**
* Method to create an initial FieldStates object based on a list of Identifiable objects
* @param fields Identifiable objects
......
......@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { coreSelector, CoreState } from '../../core.reducers';
import {
FieldState,
FieldUpdates,
Identifiable, OBJECT_UPDATES_TRASH_PATH,
ObjectUpdatesEntry,
......@@ -15,7 +16,7 @@ import {
InitializeFieldsAction,
ReinstateObjectUpdatesAction,
RemoveFieldUpdateAction,
SetEditableFieldUpdateAction
SetEditableFieldUpdateAction, SetValidFieldUpdateAction
} from './object-updates.actions';
import { filter, map } from 'rxjs/operators';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
......@@ -102,6 +103,33 @@ export class ObjectUpdatesService {
)
}
/**
* Method to check if a specific field is currently valid in the store
* @param url The URL of the page on which the field resides
* @param uuid The UUID of the field
*/
isValid(url: string, uuid: string): Observable<boolean> {
const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(
filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])),
map((objectEntry) => objectEntry.fieldStates[uuid].isValid
)
)
}
/**
* Method to check if a specific page is currently valid in the store
* @param url The URL of the page
*/
isValidPage(url: string): Observable<boolean> {
const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(
map((entry: ObjectUpdatesEntry) => {
return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0
})
)
}
/**
* Calls the saveFieldUpdate method with FieldChangeType.ADD
* @param url The page's URL for which the changes are saved
......@@ -139,6 +167,16 @@ export class ObjectUpdatesService {
this.store.dispatch(new SetEditableFieldUpdateAction(url, uuid, editable));
}
/**
* Dispatches a SetValidFieldUpdateAction to the store to set a field's isValid 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 valid The new value of isValid in the store for this field
*/
setValidFieldUpdate(url: string, uuid: string, valid: boolean) {
this.store.dispatch(new SetValidFieldUpdateAction(url, uuid, valid));
}
/**
* Method to dispatch an DiscardObjectUpdatesAction to the store
* @param url The page's URL for which the changes should be discarded
......
<ds-form *ngIf="formModel"
[formId]="'comcol-form-id'"
[formModel]="formModel" (submitForm)="onSubmit()"></ds-form>
[formModel]="formModel" (submitForm)="onSubmit()" (cancel)="onCancel()"></ds-form>
......@@ -50,10 +50,7 @@ describe('ComColFormComponent', () => {
];
/* tslint:disable:no-empty */
const locationStub = {
back: () => {
}
};
const locationStub = jasmine.createSpyObj('location', ['back']);
/* tslint:enable:no-empty */
beforeEach(async(() => {
......@@ -112,4 +109,11 @@ describe('ComColFormComponent', () => {
);
})
});
describe('onCancel', () => {
it('should call the back method on the Location service', () => {
comp.onCancel();
expect(locationStub.back).toHaveBeenCalled();
});
});
});
......@@ -112,4 +112,8 @@ export class ComColFormComponent<T extends DSpaceObject> implements OnInit {
}
);
}
onCancel() {
this.location.back();
}
}
import {
Component,
ElementRef, EventEmitter, forwardRef,
ElementRef,
EventEmitter,
forwardRef,
Input,
OnChanges,
Output,
QueryList, SimpleChanges,
QueryList,
SimpleChanges,
ViewChild,
ViewChildren
} from '@angular/core';
......@@ -19,6 +23,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
providers: [
{
provide: NG_VALUE_ACCESSOR,
// Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151
// tslint:disable-next-line:no-forward-ref
useExisting: forwardRef(() => InputSuggestionsComponent),
multi: true
}
......@@ -28,7 +34,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
/**
* Component representing a form with a autocomplete functionality
*/
export class InputSuggestionsComponent implements ControlValueAccessor {
export class InputSuggestionsComponent implements ControlValueAccessor, OnChanges {
/**
* The suggestions that should be shown
*/
......@@ -64,6 +70,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor {
*/
@Output() clickSuggestion = new EventEmitter();
/**
* Output for when something is typed in the input field
*/
@Output() typeSuggestion = new EventEmitter();
/**
* Output for when new suggestions should be requested
*/
......@@ -195,6 +206,7 @@ export class InputSuggestionsComponent implements ControlValueAccessor {
this.findSuggestions.emit(data);
}
this.blockReopen = false;
this.typeSuggestion.emit(data);
}
onSubmit(data) {
......
import { AbstractControl, ValidatorFn } from '@angular/forms';
export function inListValidator(list: string[]): ValidatorFn {
return (control: AbstractControl): {[key: string]: any} | null => {
const contains = list.indexOf(control.value) > 0;
return contains ? null : {inList: {value: control.value}} };
}
$content-spacing: $spacer * 1.5;
$button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2);
$button-min-width: 100px;
$card-height-percentage:98%;
$card-thumbnail-height:240px;
$dropdown-menu-max-height: 200px;
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment