diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts index 5055fabbd1..1107d27e56 100644 --- a/src/app/core/json-patch/json-patch-operations.service.spec.ts +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -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 { 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', () => { diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts index 6646e67862..5cf3d503a6 100644 --- a/src/app/core/json-patch/json-patch-operations.service.ts +++ b/src/app/core/json-patch/json-patch-operations.service.ts @@ -161,6 +161,18 @@ export abstract class JsonPatchOperationsService { + 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 * diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts index 8703a3af51..c621fe2aa7 100644 --- a/src/app/shared/form/builder/form-builder.service.ts +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -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); + } + } diff --git a/src/app/shared/form/form.actions.ts b/src/app/shared/form/form.actions.ts index 3eb3fb2716..5733cb90bc 100644 --- a/src/app/shared/form/form.actions.ts +++ b/src/app/shared/form/form.actions.ts @@ -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 diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 32ccc3b696..97879cc025 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -18,7 +18,7 @@ diff --git a/src/app/shared/form/form.component.spec.ts b/src/app/shared/form/form.component.spec.ts index f7a0564191..bda2b3e38c 100644 --- a/src/app/shared/form/form.component.spec.ts +++ b/src/app/shared/form/form.component.spec.ts @@ -119,7 +119,8 @@ function init() { dc_identifier_issn: null }, valid: false, - errors: [] + errors: [], + touched: {} } }; diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 7a5d3932c8..43f9bdfa90 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -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); } diff --git a/src/app/shared/form/form.reducer.spec.ts b/src/app/shared/form/form.reducer.spec.ts index 01e3e6b1ba..5547eee7a4 100644 --- a/src/app/shared/form/form.reducer.spec.ts +++ b/src/app/shared/form/form.reducer.spec.ts @@ -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); + }); + }); diff --git a/src/app/shared/form/form.reducer.ts b/src/app/shared/form/form.reducer.ts index 1d44375c0d..59b942ebc3 100644 --- a/src/app/shared/form/form.reducer.ts +++ b/src/app/shared/form/form.reducer.ts @@ -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; + } +} diff --git a/src/app/shared/form/form.service.spec.ts b/src/app/shared/form/form.service.spec.ts index ee90e377ee..a0f7f9980b 100644 --- a/src/app/shared/form/form.service.spec.ts +++ b/src/app/shared/form/form.service.spec.ts @@ -84,7 +84,8 @@ describe('FormService test suite', () => { testForm: { data: formData, valid: false, - errors: [] + errors: [], + touched: {} } }; diff --git a/src/app/shared/form/form.service.ts b/src/app/shared/form/form.service.ts index 2b0815a40e..b895a188a4 100644 --- a/src/app/shared/form/form.service.ts +++ b/src/app/shared/form/form.service.ts @@ -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 { + 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)); } diff --git a/src/app/shared/mocks/submission.mock.ts b/src/app/shared/mocks/submission.mock.ts index 98564a4d18..1fb5dcd1b3 100644 --- a/src/app/shared/mocks/submission.mock.ts +++ b/src/app/shared/mocks/submission.mock.ts @@ -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, diff --git a/src/app/shared/testing/sections-service.stub.ts b/src/app/shared/testing/sections-service.stub.ts index 2110d71d8e..3b311c5e19 100644 --- a/src/app/shared/testing/sections-service.stub.ts +++ b/src/app/shared/testing/sections-service.stub.ts @@ -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'); } diff --git a/src/app/shared/testing/submission-service.stub.ts b/src/app/shared/testing/submission-service.stub.ts index 35c3ddfee0..d9d28bde0e 100644 --- a/src/app/shared/testing/submission-service.stub.ts +++ b/src/app/shared/testing/submission-service.stub.ts @@ -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'); diff --git a/src/app/submission/form/footer/submission-form-footer.component.html b/src/app/submission/form/footer/submission-form-footer.component.html index 938c81a33f..459241ae1c 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.html +++ b/src/app/submission/form/footer/submission-form-footer.component.html @@ -12,7 +12,7 @@ diff --git a/src/app/submission/form/footer/submission-form-footer.component.spec.ts b/src/app/submission/form/footer/submission-form-footer.component.spec.ts index d9d58aa4f2..704346f445 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.spec.ts +++ b/src/app/submission/form/footer/submission-form-footer.component.spec.ts @@ -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(); + }); + }); }); diff --git a/src/app/submission/form/footer/submission-form-footer.component.ts b/src/app/submission/form/footer/submission-form-footer.component.ts index 1b885b98b8..9cabdcbf6d 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.ts +++ b/src/app/submission/form/footer/submission-form-footer.component.ts @@ -49,6 +49,11 @@ export class SubmissionFormFooterComponent implements OnChanges { */ public submissionIsInvalid: Observable = observableOf(true); + /** + * A boolean representing if submission form has unsaved modifications + */ + public hasUnsavedModification: Observable; + /** * 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); } /** diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index 73c070846c..962f216c7d 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -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 diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts index c35968c0a0..477a84531e 100644 --- a/src/app/submission/objects/submission-objects.effects.spec.ts +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -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; let store: StoreMock; - 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-', { diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index 0af7b6c275..4f6563c662 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -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; +} diff --git a/src/app/submission/objects/submission-objects.reducer.spec.ts b/src/app/submission/objects/submission-objects.reducer.spec.ts index 0c585e4bca..0431cdff79 100644 --- a/src/app/submission/objects/submission-objects.reducer.spec.ts +++ b/src/app/submission/objects/submission-objects.reducer.spec.ts @@ -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 = [ { diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts index 098160c737..9d2030ce25 100644 --- a/src/app/submission/objects/submission-objects.reducer.ts +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -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. * @@ -653,14 +695,15 @@ function initSection(state: SubmissionObjectState, action: InitSectionAction): S */ function updateSectionData(state: SubmissionObjectState, action: UpdateSectionDataAction): SubmissionObjectState { if (isNotEmpty(state[ action.payload.submissionId ]) - && isNotEmpty(state[ action.payload.submissionId ].sections[ action.payload.sectionId])) { + && isNotEmpty(state[ action.payload.submissionId ].sections[ action.payload.sectionId])) { return Object.assign({}, state, { [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { sections: Object.assign({}, state[ action.payload.submissionId ].sections, { [ 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. * diff --git a/src/app/submission/sections/form/section-form.component.spec.ts b/src/app/submission/sections/form/section-form.component.spec.ts index 79e24dd451..d649f985ca 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -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); }); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 90c2c0c38c..1f37be89e2 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -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) => 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,15 +302,19 @@ 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; - this.isUpdating = true; - this.formModel = null; - this.cdr.detectChanges(); - this.initForm(sectionData); - this.checksForErrors(errors); - this.isUpdating = false; - this.cdr.detectChanges(); + if (this.hasMetadataEnrichment(sectionData)) { + this.isUpdating = true; + this.formModel = null; + this.cdr.detectChanges(); + this.initForm(sectionData); + this.checksForErrors(errors); + this.isUpdating = false; + this.cdr.detectChanges(); + } 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); }) ) diff --git a/src/app/submission/sections/sections.service.spec.ts b/src/app/submission/sections/sections.service.spec.ts index 5c7bff13ce..75b6dfe67e 100644 --- a/src/app/submission/sections/sections.service.spec.ts +++ b/src/app/submission/sections/sections.service.spec.ts @@ -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); + }); + }); }); diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index 5aa3c1d3ea..a38d7d6981 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -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; + } + } diff --git a/src/app/submission/submission.service.spec.ts b/src/app/submission/submission.service.spec.ts index 5816c97bde..579d1c0624 100644 --- a/src/app/submission/submission.service.spec.ts +++ b/src/app/submission/submission.service.spec.ts @@ -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', () => { diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index c9b1d41b40..87d6cbc9a4 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -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, 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 + * observable with submission unsaved modification presence + */ + hasUnsavedModification(): Observable { + 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$ diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index eb961a38eb..80b383a801 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -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: [