Merge pull request #960 from 4Science/CSTPER-260

Auto-save in new Item Submission form breaks the form
This commit is contained in:
Tim Donohue
2021-01-04 10:25:41 -06:00
committed by GitHub
29 changed files with 1030 additions and 73 deletions

View File

@@ -1,9 +1,8 @@
import { getTestScheduler } from 'jasmine-marbles';
import { getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { of as observableOf } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { RequestService } from '../data/request.service';
import { SubmissionPatchRequest } from '../data/request.models';
@@ -22,6 +21,7 @@ import {
} from './json-patch-operations.actions';
import { RequestEntry } from '../data/request.reducer';
import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { _deepClone } from 'fast-json-patch/lib/helpers';
class TestService extends JsonPatchOperationsService<SubmitDataResponseDefinitionObject, SubmissionPatchRequest> {
protected linkPath = '';
@@ -196,6 +196,32 @@ describe('JsonPatchOperationsService test suite', () => {
});
});
describe('hasPendingOperations', () => {
it('should return true when there are pending operations', () => {
const expected = hot('(x|)', { x: true });
const result = service.hasPendingOperations(testJsonPatchResourceType);
expect(result).toBeObservable(expected);
});
it('should return false when there are not pending operations', () => {
const mockStateNoOp = _deepClone(mockState);
mockStateNoOp['json/patch'][testJsonPatchResourceType].children = [];
store.select.and.returnValue(observableOf(mockStateNoOp['json/patch'][testJsonPatchResourceType]));
const expected = hot('(x|)', { x: false });
const result = service.hasPendingOperations(testJsonPatchResourceType);
expect(result).toBeObservable(expected);
});
});
describe('jsonPatchByResourceID', () => {
it('should call submitJsonPatchOperations method', () => {

View File

@@ -161,6 +161,18 @@ export abstract class JsonPatchOperationsService<ResponseDefinitionDomain, Patch
return this.submitJsonPatchOperations(href$, resourceType);
}
/**
* Select the jsonPatch operation related to the specified resource type.
* @param resourceType
*/
public hasPendingOperations(resourceType: string): Observable<boolean> {
return this.store.select(jsonPatchOperationsByResourceType(resourceType)).pipe(
map((val) => !isEmpty(val) && Object.values(val.children)
.filter((section) => !isEmpty((section as any).body)).length > 0),
distinctUntilChanged(),
);
}
/**
* Make a new JSON Patch request with all operations related to the specified resource id
*

View File

@@ -7,7 +7,7 @@ import {
DYNAMIC_FORM_CONTROL_TYPE_GROUP,
DYNAMIC_FORM_CONTROL_TYPE_INPUT,
DYNAMIC_FORM_CONTROL_TYPE_RADIO_GROUP,
DynamicFormArrayModel,
DynamicFormArrayModel, DynamicFormControlEvent,
DynamicFormControlModel,
DynamicFormGroupModel,
DynamicFormService, DynamicFormValidationService,
@@ -16,7 +16,7 @@ import {
import { isObject, isString, mergeWith } from 'lodash';
import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined, isNull } from '../../empty.util';
import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model';
import {DynamicQualdropModel} from './ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model';
import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model';
import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model';
import { RowParser } from './parsers/row-parser';
@@ -26,6 +26,7 @@ import { DsDynamicInputModel } from './ds-dynamic-form-ui/models/ds-dynamic-inpu
import { FormFieldMetadataValueObject } from './models/form-field-metadata-value.model';
import { isNgbDateStruct } from '../../date.util';
import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './ds-dynamic-form-ui/ds-dynamic-form-constants';
import { CONCAT_GROUP_SUFFIX, DynamicConcatModel } from './ds-dynamic-form-ui/models/ds-dynamic-concat.model';
@Injectable()
export class FormBuilderService extends DynamicFormService {
@@ -54,6 +55,13 @@ export class FormBuilderService extends DynamicFormService {
break;
}
if (this.isConcatGroup(controlModel)) {
if (controlModel.id.match(new RegExp(findId + CONCAT_GROUP_SUFFIX + `_\\d+$`))) {
result = (controlModel as DynamicConcatModel).group[0];
break;
}
}
if (this.isGroup(controlModel)) {
findByIdFn(findId, (controlModel as DynamicFormGroupModel).group, findArrayIndex);
}
@@ -247,6 +255,10 @@ export class FormBuilderService extends DynamicFormService {
return model && ((model as any).type === DYNAMIC_FORM_CONTROL_TYPE_GROUP && (model as any).isCustomGroup === true);
}
isConcatGroup(model: DynamicFormControlModel): boolean {
return this.isCustomGroup(model) && (model.id.indexOf(CONCAT_GROUP_SUFFIX) !== -1);
}
isRowGroup(model: DynamicFormControlModel): boolean {
return model && ((model as any).type === DYNAMIC_FORM_CONTROL_TYPE_GROUP && (model as any).isRowGroup === true);
}
@@ -303,4 +315,76 @@ export class FormBuilderService extends DynamicFormService {
return (tempModel.id !== tempModel.name) ? tempModel.name : tempModel.id;
}
/**
* Calculate the metadata list related to the event.
* @param event
*/
getMetadataIdsFromEvent(event: DynamicFormControlEvent): string[] {
let model = event.model;
while (model.parent) {
model = model.parent as any;
}
const iterateControlModels = (findGroupModel: DynamicFormControlModel[], controlModelIndex: number = 0): string[] => {
let iterateResult = Object.create({});
// Iterate over all group's controls
for (const controlModel of findGroupModel) {
if (this.isRowGroup(controlModel) && !this.isCustomOrListGroup(controlModel)) {
iterateResult = mergeWith(iterateResult, iterateControlModels((controlModel as DynamicFormGroupModel).group));
continue;
}
if (this.isGroup(controlModel) && !this.isCustomOrListGroup(controlModel)) {
iterateResult[controlModel.name] = iterateControlModels((controlModel as DynamicFormGroupModel).group);
continue;
}
if (this.isRowArrayGroup(controlModel)) {
for (const arrayItemModel of (controlModel as DynamicRowArrayModel).groups) {
iterateResult = mergeWith(iterateResult, iterateControlModels(arrayItemModel.group, arrayItemModel.index));
}
continue;
}
if (this.isArrayGroup(controlModel)) {
iterateResult[controlModel.name] = [];
for (const arrayItemModel of (controlModel as DynamicFormArrayModel).groups) {
iterateResult[controlModel.name].push(iterateControlModels(arrayItemModel.group, arrayItemModel.index));
}
continue;
}
let controlId;
// Get the field's name
if (this.isQualdropGroup(controlModel)) {
// If is instance of DynamicQualdropModel take the qualdrop id as field's name
controlId = (controlModel as DynamicQualdropModel).qualdropId;
} else {
controlId = controlModel.name;
}
if (this.isRelationGroup(controlModel)) {
const values = (controlModel as DynamicRelationGroupModel).getGroupValue();
values.forEach((groupValue, groupIndex) => {
Object.keys(groupValue).forEach((key) => {
iterateResult[key] = true;
});
});
} else {
iterateResult[controlId] = true;
}
}
return iterateResult;
};
const result = iterateControlModels([model]);
return Object.keys(result);
}
}

View File

@@ -13,6 +13,7 @@ import { type } from '../ngrx/type';
export const FormActionTypes = {
FORM_INIT: type('dspace/form/FORM_INIT'),
FORM_CHANGE: type('dspace/form/FORM_CHANGE'),
FORM_ADD_TOUCHED: type('dspace/form/FORM_ADD_TOUCHED'),
FORM_REMOVE: type('dspace/form/FORM_REMOVE'),
FORM_STATUS_CHANGE: type('dspace/form/FORM_STATUS_CHANGE'),
FORM_ADD_ERROR: type('dspace/form/FORM_ADD_ERROR'),
@@ -52,7 +53,7 @@ export class FormChangeAction implements Action {
};
/**
* Create a new FormInitAction
* Create a new FormChangeAction
*
* @param formId
* the Form's ID
@@ -64,6 +65,26 @@ export class FormChangeAction implements Action {
}
}
export class FormAddTouchedAction implements Action {
type = FormActionTypes.FORM_ADD_TOUCHED;
payload: {
formId: string;
touched: string[];
};
/**
* Create a new FormAddTouchedAction
*
* @param formId
* the Form's ID
* @param touched
* the array containing new touched fields
*/
constructor(formId: string, touched: string[]) {
this.payload = {formId, touched};
}
}
export class FormRemoveAction implements Action {
type = FormActionTypes.FORM_REMOVE;
payload: {
@@ -147,6 +168,7 @@ export class FormClearErrorsAction implements Action {
*/
export type FormAction = FormInitAction
| FormChangeAction
| FormAddTouchedAction
| FormRemoveAction
| FormStatusChangeAction
| FormAddError

View File

@@ -18,7 +18,7 @@
<button type="button" class="btn btn-secondary"
[disabled]="isItemReadOnly(context, index)"
(click)="insertItem($event, group.context, group.index)">
<i class="fas fa-plus" aria-hidden="true"></i>
<span aria-label="Add">{{'form.add' | translate}}</span>
</button>
</div>
</div>

View File

@@ -119,7 +119,8 @@ function init() {
dc_identifier_issn: null
},
valid: false,
errors: []
errors: [],
touched: {}
}
};

View File

@@ -253,6 +253,7 @@ export class FormComponent implements OnDestroy, OnInit {
}
onFocus(event: DynamicFormControlEvent): void {
this.formService.setTouched(this.formId, this.formModel, event);
this.focus.emit(event);
}

View File

@@ -6,6 +6,7 @@ import {
FormInitAction,
FormRemoveAction,
FormRemoveErrorAction,
FormAddTouchedAction,
FormStatusChangeAction
} from './form.actions';
@@ -21,7 +22,8 @@ describe('formReducer', () => {
description: null
},
valid: false,
errors: []
errors: [],
touched: {}
}
};
const formId = 'testForm';
@@ -48,7 +50,8 @@ describe('formReducer', () => {
description: null
},
valid: false,
errors: []
errors: [],
touched: {}
}
};
const formId = 'testForm';
@@ -67,7 +70,8 @@ describe('formReducer', () => {
description: null
},
valid: false,
errors: []
errors: [],
touched: {}
}
};
@@ -88,7 +92,8 @@ describe('formReducer', () => {
description: null
},
valid: false,
errors: []
errors: [],
touched: {}
}
};
const state = {
@@ -100,7 +105,8 @@ describe('formReducer', () => {
description: null
},
valid: false,
errors: []
errors: [],
touched: {}
}
};
const formId = 'testForm';
@@ -127,7 +133,8 @@ describe('formReducer', () => {
description: null
},
valid: false,
errors: []
errors: [],
touched: {}
}
};
const state = {
@@ -139,7 +146,8 @@ describe('formReducer', () => {
description: null
},
valid: true,
errors: []
errors: [],
touched: {}
}
};
const formId = 'testForm';
@@ -160,7 +168,8 @@ describe('formReducer', () => {
description: null
},
valid: true,
errors: []
errors: [],
touched: {}
}
};
@@ -204,7 +213,8 @@ describe('formReducer', () => {
fieldIndex: 0,
message: 'error.validation.required'
}
]
],
touched: {}
}
};
@@ -236,7 +246,8 @@ describe('formReducer', () => {
description: null
},
valid: true,
errors: []
errors: [],
touched: {}
}
};
@@ -264,7 +275,8 @@ describe('formReducer', () => {
fieldIndex: 0,
message: 'error.validation.required'
}
]
],
touched: {}
}
};
@@ -275,4 +287,84 @@ describe('formReducer', () => {
expect(newState.testForm.errors).toEqual([]);
});
it('should set new touched field to the form state', () => {
const initState = {
testForm: {
data: {
author: null,
title: ['test'],
date: null,
description: null
},
valid: false,
errors: [],
touched: {}
}
};
const state = {
testForm: {
data: {
author: null,
title: ['test'],
date: null,
description: null
},
valid: false,
errors: [],
touched: {
title: true
}
}
};
const formId = 'testForm';
const touched = ['title'];
const action = new FormAddTouchedAction(formId, touched);
const newState = formReducer(initState, action);
expect(newState).toEqual(state);
});
it('should add new touched field to the form state', () => {
const initState = {
testForm: {
data: {
author: null,
title: ['test'],
date: null,
description: null
},
valid: false,
errors: [],
touched: {
title: true
}
}
};
const state = {
testForm: {
data: {
author: null,
title: ['test'],
date: null,
description: null
},
valid: false,
errors: [],
touched: {
title: true,
author: true
}
}
};
const formId = 'testForm';
const touched = ['author'];
const action = new FormAddTouchedAction(formId, touched);
const newState = formReducer(initState, action);
expect(newState).toEqual(state);
});
});

View File

@@ -4,9 +4,8 @@ import {
FormAddError,
FormChangeAction, FormClearErrorsAction,
FormInitAction,
FormRemoveAction,
FormRemoveErrorAction,
FormStatusChangeAction
FormRemoveAction, FormRemoveErrorAction,
FormStatusChangeAction, FormAddTouchedAction
} from './form.actions';
import { hasValue } from '../empty.util';
import { isEqual, uniqWith } from 'lodash';
@@ -17,10 +16,15 @@ export interface FormError {
fieldIndex: number;
}
export interface FormTouchedState {
[key: string]: boolean
}
export interface FormEntry {
data: any;
valid: boolean;
errors: FormError[];
touched: FormTouchedState;
}
export interface FormState {
@@ -40,6 +44,10 @@ export function formReducer(state = initialState, action: FormAction): FormState
return changeDataForm(state, action as FormChangeAction);
}
case FormActionTypes.FORM_ADD_TOUCHED: {
return changeTouchedState(state, action as FormAddTouchedAction);
}
case FormActionTypes.FORM_REMOVE: {
return removeForm(state, action as FormRemoveAction);
}
@@ -127,7 +135,8 @@ function initForm(state: FormState, action: FormInitAction): FormState {
const formState = {
data: action.payload.formData,
valid: action.payload.valid,
errors: []
touched: {},
errors: [],
};
if (!hasValue(state[action.payload.formId])) {
return Object.assign({}, state, {
@@ -212,3 +221,24 @@ function removeForm(state: FormState, action: FormRemoveAction): FormState {
return state;
}
}
/**
* Compute the touched state of the form. New touched fields are merged with the previous ones.
* @param state
* @param action
*/
function changeTouchedState(state: FormState, action: FormAddTouchedAction): FormState {
if (hasValue(state[action.payload.formId])) {
const newState = Object.assign({}, state);
const newForm = Object.assign({}, newState[action.payload.formId]);
newState[action.payload.formId] = newForm;
newForm.touched = { ... newForm.touched};
action.payload.touched.forEach((field) => newForm.touched[field] = true);
return newState;
} else {
return state;
}
}

View File

@@ -84,7 +84,8 @@ describe('FormService test suite', () => {
testForm: {
data: formData,
valid: false,
errors: []
errors: [],
touched: {}
}
};

View File

@@ -1,5 +1,5 @@
import { map, distinctUntilChanged, filter } from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import { select, Store } from '@ngrx/store';
@@ -7,16 +7,16 @@ import { select, Store } from '@ngrx/store';
import { AppState } from '../../app.reducer';
import { formObjectFromIdSelector } from './selectors';
import { FormBuilderService } from './builder/form-builder.service';
import { DynamicFormControlModel } from '@ng-dynamic-forms/core';
import { DynamicFormControlEvent, DynamicFormControlModel } from '@ng-dynamic-forms/core';
import { isEmpty, isNotUndefined } from '../empty.util';
import { uniqueId } from 'lodash';
import {
FormChangeAction,
FormInitAction,
FormRemoveAction, FormRemoveErrorAction,
FormRemoveAction, FormRemoveErrorAction, FormAddTouchedAction,
FormStatusChangeAction
} from './form.actions';
import { FormEntry } from './form.reducer';
import { FormEntry, FormTouchedState } from './form.reducer';
import { environment } from '../../../environments/environment';
@Injectable()
@@ -51,6 +51,18 @@ export class FormService {
);
}
/**
* Method to retrieve form's touched state
*/
public getFormTouchedState(formId: string): Observable<FormTouchedState> {
return this.store.pipe(
select(formObjectFromIdSelector(formId)),
filter((state) => isNotUndefined(state)),
map((state) => state.touched),
distinctUntilChanged()
);
}
/**
* Method to retrieve form's errors from state
*/
@@ -169,6 +181,11 @@ export class FormService {
this.store.dispatch(new FormChangeAction(formId, this.formBuilderService.getValueFromModel(model)));
}
public setTouched(formId: string, model: DynamicFormControlModel[], event: DynamicFormControlEvent) {
const ids = this.formBuilderService.getMetadataIdsFromEvent(event);
this.store.dispatch(new FormAddTouchedAction(formId, ids));
}
public removeError(formId: string, eventModelId: string, fieldIndex: number) {
this.store.dispatch(new FormRemoveErrorAction(formId, eventModelId, fieldIndex));
}

View File

@@ -60,6 +60,21 @@ export const mockSectionsErrors = [
}
];
export const mockSectionsErrorsTwo = [
{
message: 'error.validation.required',
paths: [
'/sections/traditionalpageone/dc.title',
]
},
{
message: 'error.validation.license.notgranted',
paths: [
'/sections/license'
]
}
];
export const mockUploadResponse1Errors = {
errors: [
{
@@ -1033,6 +1048,7 @@ export const mockSubmissionState: SubmissionObjectState = Object.assign({}, {
enabled: true,
data: {},
errors: [],
formId: '2_traditionalpageone',
isLoading: false,
isValid: false
} as any,

View File

@@ -2,6 +2,7 @@ export class SectionsServiceStub {
checkSectionErrors = jasmine.createSpy('checkSectionErrors');
dispatchRemoveSectionErrors = jasmine.createSpy('dispatchRemoveSectionErrors');
dispatchSetSectionFormId = jasmine.createSpy('dispatchSetSectionFormId');
getSectionData = jasmine.createSpy('getSectionData');
getSectionErrors = jasmine.createSpy('getSectionErrors');
getSectionState = jasmine.createSpy('getSectionState');
@@ -14,5 +15,5 @@ export class SectionsServiceStub {
updateSectionData = jasmine.createSpy('updateSectionData');
setSectionError = jasmine.createSpy('setSectionError');
setSectionStatus = jasmine.createSpy('setSectionStatus');
computeSectionConfiguredMetadata = jasmine.createSpy('computeSectionConfiguredMetadata');
}

View File

@@ -20,6 +20,7 @@ export class SubmissionServiceStub {
getSubmissionStatus = jasmine.createSpy('getSubmissionStatus');
getSubmissionSaveProcessingStatus = jasmine.createSpy('getSubmissionSaveProcessingStatus');
getSubmissionDepositProcessingStatus = jasmine.createSpy('getSubmissionDepositProcessingStatus');
hasUnsavedModification = jasmine.createSpy('hasUnsavedModification');
isSectionHidden = jasmine.createSpy('isSectionHidden');
isSubmissionLoading = jasmine.createSpy('isSubmissionLoading');
notifyNewSection = jasmine.createSpy('notifyNewSection');

View File

@@ -12,7 +12,7 @@
<button type="button"
class="btn btn-info"
id="save"
[disabled]="(processingSaveStatus | async)"
[disabled]="(processingSaveStatus | async) || !(hasUnsavedModification | async)"
(click)="save($event)">
<span>{{'submission.general.save' | translate}}</span>
</button>

View File

@@ -169,7 +169,7 @@ describe('SubmissionFormFooterComponent Component', () => {
comp.save(null);
fixture.detectChanges();
expect(submissionServiceStub.dispatchSave).toHaveBeenCalledWith(submissionId);
expect(submissionServiceStub.dispatchSave).toHaveBeenCalledWith(submissionId, true);
});
it('should call dispatchSaveForLater on save for later', () => {
@@ -224,6 +224,22 @@ describe('SubmissionFormFooterComponent Component', () => {
expect(depositBtn.nativeElement.disabled).toBeFalsy();
});
it('should disable save button when all modifications had been saved', () => {
comp.hasUnsavedModification = observableOf(false);
fixture.detectChanges();
const saveBtn: any = fixture.debugElement.query(By.css('#save'));
expect(saveBtn.nativeElement.disabled).toBeTruthy();
});
it('should enable save button when there are not saved modifications', () => {
comp.hasUnsavedModification = observableOf(true);
fixture.detectChanges();
const saveBtn: any = fixture.debugElement.query(By.css('#save'));
expect(saveBtn.nativeElement.disabled).toBeFalsy();
});
});
});

View File

@@ -49,6 +49,11 @@ export class SubmissionFormFooterComponent implements OnChanges {
*/
public submissionIsInvalid: Observable<boolean> = observableOf(true);
/**
* A boolean representing if submission form has unsaved modifications
*/
public hasUnsavedModification: Observable<boolean>;
/**
* Initialize instance variables
*
@@ -73,6 +78,7 @@ export class SubmissionFormFooterComponent implements OnChanges {
this.processingSaveStatus = this.submissionService.getSubmissionSaveProcessingStatus(this.submissionId);
this.processingDepositStatus = this.submissionService.getSubmissionDepositProcessingStatus(this.submissionId);
this.showDepositAndDiscard = observableOf(this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkspaceItem);
this.hasUnsavedModification = this.submissionService.hasUnsavedModification();
}
}
@@ -80,7 +86,7 @@ export class SubmissionFormFooterComponent implements OnChanges {
* Dispatch a submission save action
*/
save(event) {
this.submissionService.dispatchSave(this.submissionId);
this.submissionService.dispatchSave(this.submissionId, true);
}
/**

View File

@@ -40,6 +40,7 @@ export const SubmissionObjectActionTypes = {
INIT_SECTION: type('dspace/submission/INIT_SECTION'),
ENABLE_SECTION: type('dspace/submission/ENABLE_SECTION'),
DISABLE_SECTION: type('dspace/submission/DISABLE_SECTION'),
SET_SECTION_FORM_ID: type('dspace/submission/SET_SECTION_FORM_ID'),
SECTION_STATUS_CHANGE: type('dspace/submission/SECTION_STATUS_CHANGE'),
SECTION_LOADING_STATUS_CHANGE: type('dspace/submission/SECTION_LOADING_STATUS_CHANGE'),
UPDATE_SECTION_DATA: type('dspace/submission/UPDATE_SECTION_DATA'),
@@ -206,6 +207,7 @@ export class UpdateSectionDataAction implements Action {
sectionId: string;
data: WorkspaceitemSectionDataType;
errors: SubmissionSectionError[];
metadata: string[];
};
/**
@@ -219,12 +221,15 @@ export class UpdateSectionDataAction implements Action {
* the section's data
* @param errors
* the section's errors
* @param metadata
* the section's metadata
*/
constructor(submissionId: string,
sectionId: string,
data: WorkspaceitemSectionDataType,
errors: SubmissionSectionError[]) {
this.payload = { submissionId, sectionId, data, errors };
errors: SubmissionSectionError[],
metadata?: string[]) {
this.payload = { submissionId, sectionId, data, errors, metadata };
}
}
@@ -252,6 +257,29 @@ export class RemoveSectionErrorsAction implements Action {
}
}
export class SetSectionFormId implements Action {
type = SubmissionObjectActionTypes.SET_SECTION_FORM_ID;
payload: {
submissionId: string;
sectionId: string;
formId: string;
};
/**
* Create a new SetSectionFormId
*
* @param submissionId
* the submission's ID
* @param sectionId
* the section's ID
* @param formId
* the section's formId
*/
constructor(submissionId: string, sectionId: string, formId: string) {
this.payload = { submissionId, sectionId, formId };
}
}
// Submission actions
export class CompleteInitSubmissionFormAction implements Action {
@@ -368,6 +396,7 @@ export class SaveSubmissionFormAction implements Action {
type = SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM;
payload: {
submissionId: string;
isManual?: boolean;
};
/**
@@ -376,8 +405,8 @@ export class SaveSubmissionFormAction implements Action {
* @param submissionId
* the submission's ID
*/
constructor(submissionId: string) {
this.payload = { submissionId };
constructor(submissionId: string, isManual: boolean = false) {
this.payload = { submissionId, isManual };
}
}
@@ -777,6 +806,7 @@ export class DeleteUploadedFileAction implements Action {
*/
export type SubmissionObjectAction = DisableSectionAction
| InitSectionAction
| SetSectionFormId
| EnableSectionAction
| InitSubmissionFormAction
| ResetSubmissionFormAction

View File

@@ -32,7 +32,7 @@ import {
mockSubmissionId,
mockSubmissionSelfUrl,
mockSubmissionState,
mockSubmissionRestResponse
mockSubmissionRestResponse, mockSectionsErrorsTwo
} from '../../shared/mocks/submission.mock';
import { SubmissionSectionModel } from '../../core/config/models/config-submission-section.model';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
@@ -51,15 +51,16 @@ import { Item } from '../../core/shared/item.model';
import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service';
import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import {formStateSelector} from '../../shared/form/selectors';
describe('SubmissionObjectEffects test suite', () => {
let submissionObjectEffects: SubmissionObjectEffects;
let actions: Observable<any>;
let store: StoreMock<AppState>;
const notificationsServiceStub = new NotificationsServiceStub();
const submissionServiceStub = new SubmissionServiceStub();
const submissionJsonPatchOperationsServiceStub = new SubmissionJsonPatchOperationsServiceStub();
let notificationsServiceStub;
let submissionServiceStub;
let submissionJsonPatchOperationsServiceStub;
const collectionId: string = mockSubmissionCollectionId;
const submissionId: string = mockSubmissionId;
const submissionDefinitionResponse: any = mockSubmissionDefinitionResponse;
@@ -68,6 +69,11 @@ describe('SubmissionObjectEffects test suite', () => {
const submissionState: any = Object.assign({}, mockSubmissionState);
beforeEach(() => {
notificationsServiceStub = new NotificationsServiceStub();
submissionServiceStub = new SubmissionServiceStub();
submissionJsonPatchOperationsServiceStub = new SubmissionJsonPatchOperationsServiceStub();
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({}, storeModuleConfig),
@@ -206,6 +212,52 @@ describe('SubmissionObjectEffects test suite', () => {
expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected);
});
it('should enable notifications if is manual', () => {
actions = hot('--a-', {
a: {
type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM,
payload: {
submissionId: submissionId,
isManual: true
}
}
});
submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(mockSubmissionRestResponse));
const expected = cold('--b-', {
b: new SaveSubmissionFormSuccessAction(
submissionId,
mockSubmissionRestResponse as any,
true
)
});
expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected);
});
it('should disable notifications if is not manual', () => {
actions = hot('--a-', {
a: {
type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM,
payload: {
submissionId: submissionId,
isManual: false
}
}
});
submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(mockSubmissionRestResponse));
const expected = cold('--b-', {
b: new SaveSubmissionFormSuccessAction(
submissionId,
mockSubmissionRestResponse as any,
false
)
});
expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected);
});
it('should return a SAVE_SUBMISSION_FORM_ERROR action on error', () => {
actions = hot('--a-', {
a: {
@@ -292,7 +344,8 @@ describe('SubmissionObjectEffects test suite', () => {
type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS,
payload: {
submissionId: submissionId,
submissionObject: response
submissionObject: response,
notify: true
}
}
});
@@ -324,6 +377,61 @@ describe('SubmissionObjectEffects test suite', () => {
});
it('should not display errors when notification are disabled and field are not touched', () => {
store.nextState({
submission: {
objects: submissionState
},
forms: {
'2_traditionalpageone': {
touched: {
'dc.title': true
}
}
}
} as any);
const response = [Object.assign({}, mockSubmissionRestResponse[0], {
sections: mockSectionsData,
errors: mockSectionsErrors
})];
actions = hot('--a-', {
a: {
type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS,
payload: {
submissionId: submissionId,
submissionObject: response,
notify: false
}
}
});
const errorsList = parseSectionErrors(mockSectionsErrorsTwo);
const expected = cold('--(bcd)-', {
b: new UpdateSectionDataAction(
submissionId,
'traditionalpageone',
mockSectionsData.traditionalpageone as any,
errorsList.traditionalpageone
),
c: new UpdateSectionDataAction(
submissionId,
'license',
mockSectionsData.license as any,
errorsList.license || []
),
d: new UpdateSectionDataAction(
submissionId,
'upload',
mockSectionsData.upload as any,
errorsList.upload || []
),
});
expect(submissionObjectEffects.saveSubmissionSectionSuccess$).toBeObservable(expected);
expect(notificationsServiceStub.warning).not.toHaveBeenCalled();
});
it('should display a success notification', () => {
store.nextState({
submission: {
@@ -471,6 +579,203 @@ describe('SubmissionObjectEffects test suite', () => {
});
describe('saveSubmissionSectionSuccess$', () => {
it('should return a UPDATE_SECTION_DATA action for each updated section', () => {
store.nextState({
submission: {
objects: submissionState
}
} as any);
const response = [Object.assign({}, mockSubmissionRestResponse[0], {
sections: mockSectionsData,
errors: mockSectionsErrors
})];
actions = hot('--a-', {
a: {
type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS,
payload: {
submissionId: submissionId,
submissionObject: response
}
}
});
const errorsList = parseSectionErrors(mockSectionsErrors);
const expected = cold('--(bcd)-', {
b: new UpdateSectionDataAction(
submissionId,
'traditionalpageone',
mockSectionsData.traditionalpageone as any,
[]
),
c: new UpdateSectionDataAction(
submissionId,
'license',
mockSectionsData.license as any,
errorsList.license || []
),
d: new UpdateSectionDataAction(
submissionId,
'upload',
mockSectionsData.upload as any,
errorsList.upload || []
),
});
expect(submissionObjectEffects.saveSubmissionSectionSuccess$).toBeObservable(expected);
});
it('should not display a success notification', () => {
store.nextState({
submission: {
objects: submissionState
}
} as any);
const response = [Object.assign({}, mockSubmissionRestResponse[0], {
sections: mockSectionsData
})];
actions = hot('--a-', {
a: {
type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS,
payload: {
submissionId: submissionId,
submissionObject: response
}
}
});
const expected = cold('--(bcd)-', {
b: new UpdateSectionDataAction(
submissionId,
'traditionalpageone',
mockSectionsData.traditionalpageone as any,
[]
),
c: new UpdateSectionDataAction(
submissionId,
'license',
mockSectionsData.license as any,
[]
),
d: new UpdateSectionDataAction(
submissionId,
'upload',
mockSectionsData.upload as any,
[]
),
});
expect(submissionObjectEffects.saveSubmissionSectionSuccess$).toBeObservable(expected);
expect(notificationsServiceStub.success).not.toHaveBeenCalled();
});
it('should not display a warning notification when there are errors', () => {
store.nextState({
submission: {
objects: submissionState
}
} as any);
const response = [Object.assign({}, mockSubmissionRestResponse[0], {
sections: mockSectionsData,
errors: mockSectionsErrors
})];
actions = hot('--a-', {
a: {
type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS,
payload: {
submissionId: submissionId,
submissionObject: response
}
}
});
const errorsList = parseSectionErrors(mockSectionsErrors);
console.log(errorsList);
const expected = cold('--(bcd)-', {
b: new UpdateSectionDataAction(
submissionId,
'traditionalpageone',
mockSectionsData.traditionalpageone as any,
[]
),
c: new UpdateSectionDataAction(
submissionId,
'license',
mockSectionsData.license as any,
errorsList.license || []
),
d: new UpdateSectionDataAction(
submissionId,
'upload',
mockSectionsData.upload as any,
errorsList.upload || []
),
});
expect(submissionObjectEffects.saveSubmissionSectionSuccess$).toBeObservable(expected);
expect(notificationsServiceStub.warning).not.toHaveBeenCalled();
});
it('should detect new sections but not notify for it', () => {
store.nextState({
submission: {
objects: submissionState
}
} as any);
const response = [Object.assign({}, mockSubmissionRestResponse[0], {
sections: mockSectionsDataTwo,
errors: mockSectionsErrors
})];
actions = hot('--a-', {
a: {
type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS,
payload: {
submissionId: submissionId,
submissionObject: response,
}
}
});
const errorsList = parseSectionErrors(mockSectionsErrors);
const expected = cold('--(bcde)-', {
b: new UpdateSectionDataAction(
submissionId,
'traditionalpageone',
mockSectionsDataTwo.traditionalpageone as any,
[]
),
c: new UpdateSectionDataAction(
submissionId,
'traditionalpagetwo',
mockSectionsDataTwo.traditionalpagetwo as any,
errorsList.traditionalpagetwo || []
),
d: new UpdateSectionDataAction(
submissionId,
'license',
mockSectionsDataTwo.license as any,
errorsList.license || []
),
e: new UpdateSectionDataAction(
submissionId,
'upload',
mockSectionsDataTwo.upload as any,
errorsList.upload || []
),
});
expect(submissionObjectEffects.saveSubmissionSectionSuccess$).toBeObservable(expected);
expect(submissionServiceStub.notifyNewSection).not.toHaveBeenCalled();
});
});
describe('saveSection$', () => {
it('should return a SAVE_SUBMISSION_SECTION_FORM_SUCCESS action on success', () => {
actions = hot('--a-', {

View File

@@ -52,12 +52,14 @@ import {
UpdateSectionDataAction,
UpdateSectionDataSuccessAction
} from './submission-objects.actions';
import { SubmissionObjectEntry, SubmissionSectionObject } from './submission-objects.reducer';
import {SubmissionObjectEntry, SubmissionSectionError, SubmissionSectionObject} from './submission-objects.reducer';
import { Item } from '../../core/shared/item.model';
import { RemoteData } from '../../core/data/remote-data';
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { SubmissionObjectDataService } from '../../core/submission/submission-object-data.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
import parseSectionErrorPaths, {SectionErrorPath} from '../utils/parseSectionErrorPaths';
import { FormState } from '../../shared/form/form.reducer';
@Injectable()
export class SubmissionObjectEffects {
@@ -132,7 +134,7 @@ export class SubmissionObjectEffects {
this.submissionService.getSubmissionObjectLinkName(),
action.payload.submissionId,
'sections').pipe(
map((response: SubmissionObject[]) => new SaveSubmissionFormSuccessAction(action.payload.submissionId, response)),
map((response: SubmissionObject[]) => new SaveSubmissionFormSuccessAction(action.payload.submissionId, response, action.payload.isManual)),
catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId))));
}));
@@ -154,10 +156,24 @@ export class SubmissionObjectEffects {
* Call parseSaveResponse and dispatch actions
*/
@Effect() saveSubmissionSuccess$ = this.actions$.pipe(
ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS),
ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS),
withLatestFrom(this.store$),
map(([action, currentState]: [SaveSubmissionFormSuccessAction | SaveSubmissionSectionFormSuccessAction, any]) => {
return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId], action.payload.submissionObject, action.payload.submissionId, action.payload.notify);
map(([action, currentState]: [SaveSubmissionFormSuccessAction, any]) => {
return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId],
action.payload.submissionObject, action.payload.submissionId, currentState.forms, action.payload.notify);
}),
mergeMap((actions) => observableFrom(actions)));
/**
* Call parseSaveResponse and dispatch actions.
* Notification system is forced to be disabled.
*/
@Effect() saveSubmissionSectionSuccess$ = this.actions$.pipe(
ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS),
withLatestFrom(this.store$),
map(([action, currentState]: [SaveSubmissionSectionFormSuccessAction, any]) => {
return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId],
action.payload.submissionObject, action.payload.submissionId, currentState.forms, false);
}),
mergeMap((actions) => observableFrom(actions)));
@@ -200,7 +216,8 @@ export class SubmissionObjectEffects {
return new DepositSubmissionAction(action.payload.submissionId);
} else {
this.notificationsService.warning(null, this.translate.get('submission.sections.general.sections_not_valid'));
return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId], response, action.payload.submissionId);
return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId],
response, action.payload.submissionId, currentState.forms);
}
}),
catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId))));
@@ -280,7 +297,7 @@ export class SubmissionObjectEffects {
return item$.pipe(
map((item: Item) => item.metadata),
filter((metadata) => !isEqual(action.payload.data, metadata)),
map((metadata: any) => new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, metadata, action.payload.errors))
map((metadata: any) => new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, metadata, action.payload.errors, action.payload.metadata))
);
} else {
return observableOf(new UpdateSectionDataSuccessAction());
@@ -353,6 +370,7 @@ export class SubmissionObjectEffects {
currentState: SubmissionObjectEntry,
response: SubmissionObject[],
submissionId: string,
forms,
notify: boolean = true): SubmissionObjectAction[] {
const mappedActions = [];
@@ -392,10 +410,54 @@ export class SubmissionObjectEffects {
if (notify && !currentState.sections[sectionId].enabled) {
this.submissionService.notifyNewSection(submissionId, sectionId, currentState.sections[sectionId].sectionType);
}
mappedActions.push(new UpdateSectionDataAction(submissionId, sectionId, sectionData, sectionErrors));
const sectionForm = getForm(forms, currentState, sectionId);
const filteredErrors = filterErrors(sectionForm, sectionErrors, currentState.sections[sectionId].sectionType, notify);
mappedActions.push(new UpdateSectionDataAction(submissionId, sectionId, sectionData, filteredErrors));
}
});
}
return mappedActions;
}
}
function getForm(forms, currentState, sectionId) {
if (!forms) {
return null;
}
const formId = currentState.sections[sectionId].formId;
return forms[formId];
}
/**
* Filter sectionErrors accordingly to this rules:
* 1. if notifications are enabled return all errors
* 2. if sectionType is different from 'submission-form' return all errors
* 3. otherwise return errors only for those fields marked as touched inside the section form
* @param sectionForm
* The form related to the section
* @param sectionErrors
* The section errors array
* @param sectionType
* The section type
* @param notify
* Whether notifications are enabled
*/
function filterErrors(sectionForm: FormState, sectionErrors: SubmissionSectionError[], sectionType: string, notify: boolean): SubmissionSectionError[] {
if (notify || sectionType !== SectionsType.SubmissionForm) {
return sectionErrors;
}
if (!sectionForm || !sectionForm.touched) {
return [];
}
const filteredErrors = [];
sectionErrors.forEach((error: SubmissionSectionError) => {
const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path);
errorPaths.forEach((path: SectionErrorPath) => {
if (path.fieldId && sectionForm.touched[path.fieldId]) {
filteredErrors.push(error);
}
});
});
return filteredErrors;
}

View File

@@ -335,6 +335,17 @@ describe('submissionReducer test suite', () => {
expect(newState[826].sections.traditionalpageone.data).toEqual(data);
});
it('should update submission section metadata properly', () => {
const data = {
} as any;
const metadata = ['dc.title', 'dc.contributor.author'];
const action = new UpdateSectionDataAction(submissionId, 'traditionalpageone', data, [], metadata);
const newState = submissionObjectReducer(initState, action);
expect(newState[826].sections.traditionalpageone.metadata).toEqual(metadata);
});
it('should add submission section errors properly', () => {
const errors = [
{

View File

@@ -30,6 +30,7 @@ import {
SaveSubmissionSectionFormSuccessAction,
SectionStatusChangeAction,
SetActiveSectionAction,
SetSectionFormId,
SubmissionObjectAction,
SubmissionObjectActionTypes,
UpdateSectionDataAction
@@ -85,6 +86,11 @@ export interface SubmissionSectionObject {
*/
enabled: boolean;
/**
* The list of the metadata ids of the section.
*/
metadata: string[];
/**
* The section data object
*/
@@ -104,6 +110,11 @@ export interface SubmissionSectionObject {
* A boolean representing if this section is valid
*/
isValid: boolean;
/**
* The formId related to this section
*/
formId: string;
}
/**
@@ -258,6 +269,10 @@ export function submissionObjectReducer(state = initialState, action: Submission
return initSection(state, action as InitSectionAction);
}
case SubmissionObjectActionTypes.SET_SECTION_FORM_ID: {
return setSectionFormId(state, action as SetSectionFormId);
}
case SubmissionObjectActionTypes.ENABLE_SECTION: {
return changeSectionState(state, action as EnableSectionAction, true);
}
@@ -641,6 +656,33 @@ function initSection(state: SubmissionObjectState, action: InitSectionAction): S
}
}
/**
* Set a section form id.
*
* @param state
* the current state
* @param action
* an SetSectionFormId
* @return SubmissionObjectState
* the new state
*/
function setSectionFormId(state: SubmissionObjectState, action: SetSectionFormId): SubmissionObjectState {
if (hasValue(state[ action.payload.submissionId ])) {
return Object.assign({}, state, {
[ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], {
sections: Object.assign({}, state[ action.payload.submissionId ].sections, {
[ action.payload.sectionId ]: {
...state[ action.payload.submissionId ].sections [action.payload.sectionId],
formId: action.payload.formId
}
})
})
});
} else {
return state;
}
}
/**
* Update section's data.
*
@@ -660,7 +702,8 @@ function updateSectionData(state: SubmissionObjectState, action: UpdateSectionDa
[ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], {
enabled: true,
data: action.payload.data,
errors: action.payload.errors
errors: action.payload.errors,
metadata: reduceSectionMetadata(action.payload.metadata, state[ action.payload.submissionId ].sections [ action.payload.sectionId ].metadata)
})
})
})
@@ -670,6 +713,24 @@ function updateSectionData(state: SubmissionObjectState, action: UpdateSectionDa
}
}
/**
* Updates the state of the section metadata only when a new value is provided.
* Keep the existent otherwise.
* @param newMetadata
* @param oldMetadata
* @return
* new sectionMetadata value
*/
function reduceSectionMetadata(newMetadata: string[], oldMetadata: string[]): string[] {
if (newMetadata) {
return newMetadata;
}
if (oldMetadata) {
return [...oldMetadata];
}
return undefined;
}
/**
* Set a section state.
*

View File

@@ -287,6 +287,7 @@ describe('SubmissionSectionformComponent test suite', () => {
'dc.title': [new FormFieldMetadataValueObject('test')]
};
compAsAny.formData = {};
compAsAny.sectionMetadata = ['dc.title'];
expect(comp.hasMetadataEnrichment(newSectionData)).toBeTruthy();
});
@@ -296,7 +297,16 @@ describe('SubmissionSectionformComponent test suite', () => {
'dc.title': [new FormFieldMetadataValueObject('test')]
};
compAsAny.formData = newSectionData;
compAsAny.sectionMetadata = ['dc.title'];
expect(comp.hasMetadataEnrichment(newSectionData)).toBeFalsy();
});
it('should return false when metadata has Metadata Enrichment but not belonging to sectionMetadata', () => {
const newSectionData = {
'dc.title': [new FormFieldMetadataValueObject('test')]
};
compAsAny.formData = newSectionData;
compAsAny.sectionMetadata = [];
expect(comp.hasMetadataEnrichment(newSectionData)).toBeFalsy();
});
@@ -310,6 +320,7 @@ describe('SubmissionSectionformComponent test suite', () => {
comp.sectionData.data = {};
comp.sectionData.errors = [];
compAsAny.formData = {};
compAsAny.sectionMetadata = ['dc.title'];
comp.updateForm(sectionData, sectionError);
@@ -329,10 +340,11 @@ describe('SubmissionSectionformComponent test suite', () => {
comp.sectionData.data = {};
comp.sectionData.errors = [];
compAsAny.formData = sectionData;
compAsAny.sectionMetadata = ['dc.title'];
comp.updateForm(sectionData, parsedSectionErrors);
expect(comp.initForm).toHaveBeenCalled();
expect(comp.initForm).not.toHaveBeenCalled();
expect(comp.checksForErrors).toHaveBeenCalled();
expect(comp.sectionData.data).toEqual(sectionData);
});

View File

@@ -13,7 +13,7 @@ import {
tap
} from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { isEqual } from 'lodash';
import { isEqual, findIndex } from 'lodash';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import { FormComponent } from '../../../shared/form/form.component';
@@ -101,6 +101,12 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
*/
protected formData: any = Object.create({});
/**
* Store the
* @protected
*/
protected sectionMetadata: string[];
/**
* The [JsonPatchOperationPathCombiner] object
* @type {JsonPatchOperationPathCombiner}
@@ -168,6 +174,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
onSectionInit() {
this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id);
this.formId = this.formService.getUniqueId(this.sectionData.id);
this.sectionService.dispatchSetSectionFormId(this.submissionId, this.sectionData.id, this.formId);
this.formConfigService.findByHref(this.sectionData.config).pipe(
map((configData: RemoteData<ConfigObject>) => configData.payload),
tap((config: SubmissionFormsModel) => this.formConfig = config),
@@ -230,16 +237,25 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
* the section data retrieved from the server
*/
hasMetadataEnrichment(sectionData: WorkspaceitemSectionFormObject): boolean {
const sectionDataToCheck = {};
Object.keys(sectionData).forEach((key) => {
if (this.sectionMetadata && this.sectionMetadata.includes(key)) {
sectionDataToCheck[key] = sectionData[key];
}
})
const diffResult = [];
// compare current form data state with section data retrieved from store
const diffObj = difference(sectionData, this.formData);
const diffObj = difference(sectionDataToCheck, this.formData);
// iterate over differences to check whether they are actually different
Object.keys(diffObj)
.forEach((key) => {
diffObj[key].forEach((value) => {
if (value.hasOwnProperty('value')) {
// the findIndex extra check excludes values already present in the form but in different positions
if (value.hasOwnProperty('value') && findIndex(this.formData[key], { value: value.value }) < 0) {
diffResult.push(value);
}
});
@@ -262,6 +278,9 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
sectionData,
this.submissionService.getSubmissionScope()
);
const sectionMetadata = this.sectionService.computeSectionConfiguredMetadata(this.formConfig);
this.sectionService.updateSectionData(this.submissionId, this.sectionData.id, sectionData, [], sectionMetadata);
} catch (e) {
const msg: string = this.translate.instant('error.submission.sections.init-form-error') + e.toString();
const sectionError: SubmissionSectionError = {
@@ -283,8 +302,9 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
*/
updateForm(sectionData: WorkspaceitemSectionFormObject, errors: SubmissionSectionError[]): void {
if (hasValue(sectionData) && !isEqual(sectionData, this.sectionData.data)) {
if (isNotEmpty(sectionData) && !isEqual(sectionData, this.sectionData.data)) {
this.sectionData.data = sectionData;
if (this.hasMetadataEnrichment(sectionData)) {
this.isUpdating = true;
this.formModel = null;
this.cdr.detectChanges();
@@ -295,6 +315,9 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
} else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errors)) {
this.checksForErrors(errors);
}
} else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errors)) {
this.checksForErrors(errors);
}
}
@@ -338,6 +361,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
distinctUntilChanged())
.subscribe((sectionState: SubmissionSectionObject) => {
this.fieldsOnTheirWayToBeRemoved = new Map();
this.sectionMetadata = sectionState.metadata;
this.updateForm(sectionState.data as WorkspaceitemSectionFormObject, sectionState.errors);
})
)

View File

@@ -380,4 +380,25 @@ describe('SectionsService test suite', () => {
expect(store.dispatch).toHaveBeenCalledWith(new UpdateSectionDataAction(submissionId, sectionId, data, []));
});
});
describe('computeSectionConfiguredMetadata', () => {
it('should return the configured metadata of the section from the form configuration', () => {
const formConfig = {
rows: [{
fields: [{
selectableMetadata: [{
metadata: 'dc.contributor.author'
}]
}]
}]
}
const expectedConfiguredMetadata = [ 'dc.contributor.author' ];
const configuredMetadata = service.computeSectionConfiguredMetadata(formConfig as any);
expect(configuredMetadata).toEqual(expectedConfiguredMetadata);
});
});
});

View File

@@ -15,6 +15,7 @@ import {
InertSectionErrorsAction,
RemoveSectionErrorsAction,
SectionStatusChangeAction,
SetSectionFormId,
UpdateSectionDataAction
} from '../objects/submission-objects.actions';
import {
@@ -36,6 +37,8 @@ import { SubmissionService } from '../submission.service';
import { WorkspaceitemSectionDataType } from '../../core/submission/models/workspaceitem-sections.model';
import { SectionsType } from './sections-type';
import { normalizeSectionData } from '../../core/submission/submission-response-parsing.service';
import { SubmissionFormsModel } from '../../core/config/models/config-submission-forms.model';
import { parseReviver } from '@ng-dynamic-forms/core';
/**
* A service that provides methods used in submission process.
@@ -133,6 +136,18 @@ export class SectionsService {
this.store.dispatch(new RemoveSectionErrorsAction(submissionId, sectionId));
}
/**
* Dispatch a new [SetSectionFormId]
* The submission id
* @param sectionId
* The section id
* @param formId
* The form id
*/
public dispatchSetSectionFormId(submissionId, sectionId, formId) {
this.store.dispatch(new SetSectionFormId(submissionId, sectionId, formId));
}
/**
* Return the data object for the specified section
*
@@ -335,8 +350,10 @@ export class SectionsService {
* The section data
* @param errors
* The list of section errors
* @param metadata
* The section metadata
*/
public updateSectionData(submissionId: string, sectionId: string, data: WorkspaceitemSectionDataType, errors: SubmissionSectionError[] = []) {
public updateSectionData(submissionId: string, sectionId: string, data: WorkspaceitemSectionDataType, errors: SubmissionSectionError[] = [], metadata?: string[]) {
if (isNotEmpty(data)) {
const isAvailable$ = this.isSectionAvailable(submissionId, sectionId);
const isEnabled$ = this.isSectionEnabled(submissionId, sectionId);
@@ -345,7 +362,7 @@ export class SectionsService {
take(1),
filter(([available, enabled]: [boolean, boolean]) => available))
.subscribe(([available, enabled]: [boolean, boolean]) => {
this.store.dispatch(new UpdateSectionDataAction(submissionId, sectionId, data, errors));
this.store.dispatch(new UpdateSectionDataAction(submissionId, sectionId, data, errors, metadata));
});
}
}
@@ -377,4 +394,30 @@ export class SectionsService {
public setSectionStatus(submissionId: string, sectionId: string, status: boolean) {
this.store.dispatch(new SectionStatusChangeAction(submissionId, sectionId, status));
}
/**
* Compute the list of selectable metadata for the section configuration.
* @param formConfig
*/
public computeSectionConfiguredMetadata(formConfig: string | SubmissionFormsModel): string[] {
const metadata = [];
const rawData = typeof formConfig === 'string' ? JSON.parse(formConfig, parseReviver) : formConfig;
if (rawData.rows && !isEmpty(rawData.rows)) {
rawData.rows.forEach((currentRow) => {
if (currentRow.fields && !isEmpty(currentRow.fields)) {
currentRow.fields.forEach((field) => {
if (field.selectableMetadata && !isEmpty(field.selectableMetadata)) {
field.selectableMetadata.forEach((selectableMetadata) => {
if (!metadata.includes(selectableMetadata.metadata)) {
metadata.push(selectableMetadata.metadata);
}
})
}
})
}
});
}
return metadata;
}
}

View File

@@ -46,6 +46,8 @@ import { SearchService } from '../core/shared/search/search.service';
import { Item } from '../core/shared/item.model';
import { storeModuleConfig } from '../app.reducer';
import { environment } from '../../environments/environment';
import { SubmissionJsonPatchOperationsService } from '../core/submission/submission-json-patch-operations.service';
import { SubmissionJsonPatchOperationsServiceStub } from '../shared/testing/submission-json-patch-operations-service.stub';
describe('SubmissionService test suite', () => {
const collectionId = '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f';
@@ -345,6 +347,7 @@ describe('SubmissionService test suite', () => {
const router = new RouterMock();
const selfUrl = 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826';
const submissionDefinition: any = mockSubmissionDefinition;
const submissionJsonPatchOperationsService = new SubmissionJsonPatchOperationsServiceStub();
let scheduler: TestScheduler;
let service: SubmissionService;
@@ -371,6 +374,7 @@ describe('SubmissionService test suite', () => {
{ provide: ActivatedRoute, useValue: new MockActivatedRoute() },
{ provide: SearchService, useValue: searchService },
{ provide: RequestService, useValue: requestServce },
{ provide: SubmissionJsonPatchOperationsService, useValue: submissionJsonPatchOperationsService },
NotificationsService,
RouteService,
SubmissionService,
@@ -487,11 +491,18 @@ describe('SubmissionService test suite', () => {
describe('dispatchSave', () => {
it('should dispatch a new SaveSubmissionFormAction', () => {
service.dispatchSave(submissionId,);
service.dispatchSave(submissionId);
const expected = new SaveSubmissionFormAction(submissionId);
expect((service as any).store.dispatch).toHaveBeenCalledWith(expected);
});
it('should dispatch a new SaveSubmissionFormAction with manual flag', () => {
service.dispatchSave(submissionId, true);
const expected = new SaveSubmissionFormAction(submissionId, true);
expect((service as any).store.dispatch).toHaveBeenCalledWith(expected);
});
});
describe('dispatchSaveForLater', () => {
@@ -746,6 +757,20 @@ describe('SubmissionService test suite', () => {
});
});
describe('hasUnsavedModification', () => {
it('should call jsonPatchOperationService hasPendingOperation observable', () => {
(service as any).jsonPatchOperationService.hasPendingOperations = jasmine.createSpy('hasPendingOperations')
.and.returnValue(observableOf(true));
scheduler = getTestScheduler();
scheduler.schedule(() => service.hasUnsavedModification());
scheduler.flush();
expect((service as any).jsonPatchOperationService.hasPendingOperations).toHaveBeenCalledWith('sections');
});
});
describe('isSectionHidden', () => {
it('should return true/false when section is hidden/visible', () => {
let section: any = {
@@ -915,8 +940,15 @@ describe('SubmissionService test suite', () => {
});
describe('startAutoSave', () => {
let environmentAutoSaveTimerOriginalValue;
beforeEach(() => {
environmentAutoSaveTimerOriginalValue = environment.submission.autosave.timer;
});
it('should start Auto Save', fakeAsync(() => {
const duration = environment.submission.autosave.timer * (1000 * 60);
const duration = environment.submission.autosave.timer;
service.startAutoSave('826');
const sub = (service as any).timer$.subscribe();
@@ -930,6 +962,19 @@ describe('SubmissionService test suite', () => {
sub.unsubscribe();
(service as any).autoSaveSub.unsubscribe();
}));
it('should not start Auto Save if timer is 0', fakeAsync(() => {
environment.submission.autosave.timer = 0;
service.startAutoSave('826');
expect((service as any).autoSaveSub).toBeUndefined();
}));
afterEach(() => {
environment.submission.autosave.timer = environmentAutoSaveTimerOriginalValue;
})
});
describe('stopAutoSave', () => {

View File

@@ -45,6 +45,7 @@ import { RequestService } from '../core/data/request.service';
import { SearchService } from '../core/shared/search/search.service';
import { Item } from '../core/shared/item.model';
import { environment } from '../../environments/environment';
import { SubmissionJsonPatchOperationsService } from '../core/submission/submission-json-patch-operations.service';
/**
* A service that provides methods used in submission process.
@@ -82,7 +83,8 @@ export class SubmissionService {
protected store: Store<SubmissionState>,
protected translate: TranslateService,
protected searchService: SearchService,
protected requestService: RequestService) {
protected requestService: RequestService,
protected jsonPatchOperationService: SubmissionJsonPatchOperationsService) {
}
/**
@@ -209,12 +211,14 @@ export class SubmissionService {
*
* @param submissionId
* The submission id
* @param manual
* whether is a manual save, default false
*/
dispatchSave(submissionId) {
dispatchSave(submissionId, manual?: boolean) {
this.getSubmissionSaveProcessingStatus(submissionId).pipe(
find((isPending: boolean) => !isPending)
).subscribe(() => {
this.store.dispatch(new SaveSubmissionFormAction(submissionId));
this.store.dispatch(new SaveSubmissionFormAction(submissionId, manual));
})
}
@@ -427,6 +431,16 @@ export class SubmissionService {
startWith(false));
}
/**
* Return whether submission unsaved modification are present
*
* @return Observable<boolean>
* observable with submission unsaved modification presence
*/
hasUnsavedModification(): Observable<boolean> {
return this.jsonPatchOperationService.hasPendingOperations('sections');
}
/**
* Return the visibility status of the specified section
*
@@ -562,9 +576,12 @@ export class SubmissionService {
*/
startAutoSave(submissionId) {
this.stopAutoSave();
if (environment.submission.autosave.timer === 0) {
return;
}
// AUTOSAVE submission
// Retrieve interval from config and convert to milliseconds
const duration = environment.submission.autosave.timer * (1000 * 60);
const duration = environment.submission.autosave.timer;
// Dispatch save action after given duration
this.timer$ = observableTimer(duration, duration);
this.autoSaveSub = this.timer$

View File

@@ -65,9 +65,9 @@ export const environment: GlobalConfig = {
submission: {
autosave: {
// NOTE: which metadata trigger an autosave
metadata: ['dc.title', 'dc.identifier.doi', 'dc.identifier.pmid', 'dc.identifier.arxiv'],
metadata: [],
// NOTE: every how many minutes submission is saved automatically
timer: 5
timer: 0
},
icons: {
metadata: [