[835] Auto-save in new Item Submission form breaks the form

Store additions:
1. Form AdditionalData: contains the list of the touched metadata
2. Submission metadata: contains the list of the metadata ids assignable for each section

We keep also track whether a section ha focused fields or not.
This commit is contained in:
Alessandro Martelli
2020-11-19 15:47:03 +01:00
parent 82b7b8aa6f
commit 8111bdd3ce
10 changed files with 299 additions and 28 deletions

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,
@@ -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,15 @@ export class FormBuilderService extends DynamicFormService {
break;
}
if (this.isConcatGroup(controlModel)) {
const concatGroupId = controlModel.id.replace(CONCAT_GROUP_SUFFIX, '');
// if (concatGroupId === findId) {
if (concatGroupId.includes(findId)) {
result = (controlModel as DynamicConcatModel).group[0];
break;
}
}
if (this.isGroup(controlModel)) {
findByIdFn(findId, (controlModel as DynamicFormGroupModel).group, findArrayIndex);
}
@@ -247,6 +257,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 +317,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): void => {
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 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_ADDITIONAL: type('dspace/form/FORM_ADDITIONAL'),
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'),
@@ -27,6 +28,7 @@ export class FormInitAction implements Action {
formId: string;
formData: any;
valid: boolean;
formAdditional: any;
};
/**
@@ -39,8 +41,8 @@ export class FormInitAction implements Action {
* @param valid
* the Form validation status
*/
constructor(formId: string, formData: any, valid: boolean) {
this.payload = {formId, formData, valid};
constructor(formId: string, formData: any, valid: boolean, formAdditional?: any) {
this.payload = {formId, formData, valid, formAdditional};
}
}
@@ -52,7 +54,7 @@ export class FormChangeAction implements Action {
};
/**
* Create a new FormInitAction
* Create a new FormChangeAction
*
* @param formId
* the Form's ID
@@ -64,6 +66,26 @@ export class FormChangeAction implements Action {
}
}
export class FormSetAdditionalAction implements Action {
type = FormActionTypes.FORM_ADDITIONAL;
payload: {
formId: string;
additionalData: any;
};
/**
* Create a new FormSetAdditionalAction
*
* @param formId
* the Form's ID
* @param additionalData
* the additionalData Object
*/
constructor(formId: string, additionalData: any) {
this.payload = {formId, additionalData};
}
}
export class FormRemoveAction implements Action {
type = FormActionTypes.FORM_REMOVE;
payload: {
@@ -147,6 +169,7 @@ export class FormClearErrorsAction implements Action {
*/
export type FormAction = FormInitAction
| FormChangeAction
| FormSetAdditionalAction
| FormRemoveAction
| FormStatusChangeAction
| FormAddError

View File

@@ -5,7 +5,7 @@ import {
FormChangeAction, FormClearErrorsAction,
FormInitAction,
FormRemoveAction,
FormRemoveErrorAction,
FormRemoveErrorAction, FormSetAdditionalAction,
FormStatusChangeAction
} from './form.actions';
import { hasValue } from '../empty.util';
@@ -21,6 +21,7 @@ export interface FormEntry {
data: any;
valid: boolean;
errors: FormError[];
additional: any;
}
export interface FormState {
@@ -40,6 +41,10 @@ export function formReducer(state = initialState, action: FormAction): FormState
return changeDataForm(state, action as FormChangeAction);
}
case FormActionTypes.FORM_ADDITIONAL: {
return additionalData(state, action as FormSetAdditionalAction);
}
case FormActionTypes.FORM_REMOVE: {
return removeForm(state, action as FormRemoveAction);
}
@@ -127,7 +132,8 @@ function initForm(state: FormState, action: FormInitAction): FormState {
const formState = {
data: action.payload.formData,
valid: action.payload.valid,
errors: []
errors: [],
additional: action.payload.formAdditional
};
if (!hasValue(state[action.payload.formId])) {
return Object.assign({}, state, {
@@ -212,3 +218,30 @@ function removeForm(state: FormState, action: FormRemoveAction): FormState {
return state;
}
}
/**
* Compute the additional data state of the form. New touched fields are merged with the previous ones.
* @param state
* @param action
*/
function additionalData(state: FormState, action: FormSetAdditionalAction): FormState {
if (hasValue(state[action.payload.formId])) {
const newState = Object.assign({}, state);
const newAdditional = newState[action.payload.formId].additional ? {...newState[action.payload.formId].additional} : {};
const newTouchedValue = newAdditional.touched ? {...newAdditional.touched,
...action.payload.additionalData.touched} : { ...action.payload.additionalData.touched};
newAdditional.touched = newTouchedValue;
newState[action.payload.formId] = Object.assign({}, newState[action.payload.formId], {
additional: newAdditional
}
);
return newState;
} else {
return state;
}
}

View File

@@ -7,13 +7,13 @@ 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, FormSetAdditionalAction,
FormStatusChangeAction
} from './form.actions';
import { FormEntry } from './form.reducer';
@@ -51,6 +51,18 @@ export class FormService {
);
}
/**
* Method to retrieve form's additional data from state
*/
public getFormAdditionalData(formId: string): Observable<any> {
return this.store.pipe(
select(formObjectFromIdSelector(formId)),
filter((state) => isNotUndefined(state)),
map((state) => state.additional),
distinctUntilChanged()
);
}
/**
* Method to retrieve form's errors from state
*/
@@ -149,8 +161,8 @@ export class FormService {
return (environment.form.validatorMap.hasOwnProperty(validator)) ? environment.form.validatorMap[validator] : validator;
}
public initForm(formId: string, model: DynamicFormControlModel[], valid: boolean) {
this.store.dispatch(new FormInitAction(formId, this.formBuilderService.getValueFromModel(model), valid));
public initForm(formId: string, model: DynamicFormControlModel[], valid: boolean, additional?: any) {
this.store.dispatch(new FormInitAction(formId, this.formBuilderService.getValueFromModel(model), valid, additional));
}
public setStatusChanged(formId: string, valid: boolean) {
@@ -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 FormSetAdditionalAction(formId, { touched: ids}));
}
public removeError(formId: string, eventModelId: string, fieldIndex: number) {
this.store.dispatch(new FormRemoveErrorAction(formId, eventModelId, fieldIndex));
}

View File

@@ -206,6 +206,7 @@ export class UpdateSectionDataAction implements Action {
sectionId: string;
data: WorkspaceitemSectionDataType;
errors: SubmissionSectionError[];
metadata: string[];
};
/**
@@ -219,12 +220,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 };
}
}

View File

@@ -292,7 +292,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());

View File

@@ -85,6 +85,11 @@ export interface SubmissionSectionObject {
*/
enabled: boolean;
/**
* The list of the metadata ids of the section.
*/
metadata: string[];
/**
* The section data object
*/
@@ -660,7 +665,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 +676,22 @@ 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
*/
function reduceSectionMetadata(newMetadata: string[], oldMetadata: string[]) {
if (newMetadata) {
return newMetadata;
}
if (oldMetadata) {
return [...oldMetadata];
}
return undefined;
}
/**
* Set a section state.
*

View File

@@ -2,7 +2,9 @@
<ds-form *ngIf="!isLoading && formModel" #formRef="formComponent"
[formId]="formId"
[formModel]="formModel"
[formAdditional]="formAdditionalData"
[displaySubmit]="false"
(dfBlur)="onBlur($event)"
(dfChange)="onChange($event)"
(dfFocus)="onFocus($event)"
(remove)="onRemove($event)"

View File

@@ -20,7 +20,7 @@ import { FormComponent } from '../../../shared/form/form.component';
import { FormService } from '../../../shared/form/form.service';
import { SectionModelComponent } from '../models/section.model';
import { SubmissionFormsConfigService } from '../../../core/config/submission-forms-config.service';
import { hasNoValue, hasValue, isNotEmpty, isUndefined } from '../../../shared/empty.util';
import { hasNoValue, hasValue, isEmpty, isNotEmpty, isUndefined } from '../../../shared/empty.util';
import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner';
import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model';
import {
@@ -101,6 +101,18 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
*/
protected formData: any = Object.create({});
/**
* Store the current form additional data
* @protected
*/
protected formAdditionalData: any = Object.create({});
/**
* Store the
* @protected
*/
protected sectionMetadata: string[];
/**
* The [JsonPatchOperationPathCombiner] object
* @type {JsonPatchOperationPathCombiner}
@@ -125,6 +137,12 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
*/
@ViewChild('formRef', {static: false}) private formRef: FormComponent;
/**
* Keep track whether the section is focused or not.
* @protected
*/
protected isFocused = false;
/**
* Initialize instance variables
*
@@ -230,16 +248,24 @@ 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.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')) {
if (value.hasOwnProperty('value') && !isEmpty(value.value)) {
diffResult.push(value);
}
});
@@ -262,6 +288,9 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
sectionData,
this.submissionService.getSubmissionScope()
);
this.formBuilderService.enrichWithAdditionalData(this.formModel, this.formAdditionalData);
this.sectionMetadata = this.sectionService.computeSectionConfiguredMetadata(this.formConfig);
} catch (e) {
const msg: string = this.translate.instant('error.submission.sections.init-form-error') + e.toString();
const sectionError: SubmissionSectionError = {
@@ -283,8 +312,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 +325,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);
}
}
@@ -308,6 +341,9 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
this.formService.isFormInitialized(this.formId).pipe(
find((status: boolean) => status === true && !this.isUpdating))
.subscribe(() => {
// TODO: filter these errors to only those that had been touched
this.sectionService.checkSectionErrors(this.submissionId, this.sectionData.id, this.formId, errors, this.sectionData.errors);
this.sectionData.errors = errors;
this.cdr.detectChanges();
@@ -328,6 +364,12 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
this.formData = formData;
}),
this.formService.getFormAdditionalData(this.formId).pipe(
distinctUntilChanged())
.subscribe((formAdditional) => {
this.formAdditionalData = formAdditional;
}),
/**
* Subscribe to section state
*/
@@ -375,6 +417,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
* the [[DynamicFormControlEvent]] emitted
*/
onFocus(event: DynamicFormControlEvent): void {
this.isFocused = true;
const value = this.formOperationsService.getFieldValueFromChangeEvent(event);
const path = this.formBuilderService.getPath(event.model);
if (this.formBuilderService.hasMappedGroupValue(event.model)) {
@@ -386,6 +429,17 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
}
}
/**
* Method called when a form dfBlur event is fired.
*
* @param event
* the [[DynamicFormControlEvent]] emitted
*/
onBlur(event: DynamicFormControlEvent): void {
this.isFocused = false;
}
/**
* Method called when a form remove event is fired.
* Dispatch form operations based on changes.

View File

@@ -8,7 +8,7 @@ import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scrol
import { isEqual } from 'lodash';
import { SubmissionState } from '../submission.reducers';
import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util';
import {
DisableSectionAction,
EnableSectionAction,
@@ -36,6 +36,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.
@@ -335,8 +337,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 +349,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 +381,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;
}
}