diff --git a/src/app/init.service.ts b/src/app/init.service.ts index 37956bce6c4..5c63dabea6d 100644 --- a/src/app/init.service.ts +++ b/src/app/init.service.ts @@ -13,7 +13,10 @@ import { TransferState, Type, } from '@angular/core'; -import { DYNAMIC_FORM_CONTROL_MAP_FN } from '@ng-dynamic-forms/core'; +import { + DYNAMIC_FORM_CONTROL_MAP_FN, + DYNAMIC_VALIDATORS, +} from '@ng-dynamic-forms/core'; import { select, Store, @@ -41,6 +44,7 @@ import { LocaleService } from './core/locale/locale.service'; import { HeadTagService } from './core/metadata/head-tag.service'; import { CorrelationIdService } from './correlation-id/correlation-id.service'; import { dsDynamicFormControlMapFn } from './shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn'; +import { CUSTOM_VALIDATORS } from './shared/form/builder/parsers/field-parser'; import { MenuService } from './shared/menu/menu.service'; import { MenuProviderService } from './shared/menu/menu-provider.service'; import { ThemeService } from './shared/theme-support/theme.service'; @@ -120,6 +124,10 @@ export abstract class InitService { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn, }, + { + provide: DYNAMIC_VALIDATORS, + useValue: CUSTOM_VALIDATORS, + }, ]; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 957fab2b1ee..b603ec0f32d 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -279,7 +279,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo true, true, ... itemLinksToFollow(this.fetchThumbnail, this.appConfig.item.showAccessStatuses)).pipe( - getAllSucceededRemoteData(), + getFirstSucceededRemoteData(), getRemoteDataPayload()); this.relationshipValue$ = observableCombineLatest([this.item$.pipe(take(1)), relationship$]).pipe( switchMap(([item, relationship]: [Item, Relationship]) => diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts index f3cbf8fb203..362835e3290 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts @@ -186,7 +186,6 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom */ openDropdown(sdRef: NgbDropdown) { if (!this.model.readOnly) { - this.group.markAsUntouched(); this.inputText = null; this.updatePageInfo(this.model.maxOptions, 1); this.loadOptions(false); @@ -339,4 +338,9 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom this.currentValue = result; } + onBlur(event: Event) { + super.onBlur(event); + this.group.markAsTouched(); + } + } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index c634c7d8766..936bf2824f4 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -2,6 +2,10 @@ import { Inject, InjectionToken, } from '@angular/core'; +import { + AbstractControl, + ValidatorFn, +} from '@angular/forms'; import { DynamicFormControlLayout, DynamicFormControlRelation, @@ -48,6 +52,28 @@ export const PARSER_OPTIONS: InjectionToken = new InjectionToken< */ export const REGEX_FIELD_VALIDATOR = new RegExp('(\\/?)(.+)\\1([gimsuy]*)', 'i'); +/** + * Define custom form validators here + * + * Register them by adding their key to a model's validator property, e.g: + * ```ts + * model.validators = Object.assign({}, model.validators, { notRepeatable: null }); + * ``` + */ +export const CUSTOM_VALIDATORS = new Map([ + ['notRepeatable', notRepeatableValidator], +]); + +export function notRepeatableValidator(control: AbstractControl) { + const value = control.value; + if (!Array.isArray(value) || value.length < 2) { + return null; + } + return { + notRepeatable: true, + }; +} + export abstract class FieldParser { protected fieldId: string; @@ -131,7 +157,11 @@ export abstract class FieldParser { }, }; - return new DynamicRowArrayModel(config, layout); + const model = new DynamicRowArrayModel(config, layout); + if (config.notRepeatable) { + this.addNotRepeatableValidator(model); + } + return model; } else { const model = this.modelFactory(this.getInitFieldValue()); @@ -426,6 +456,17 @@ export abstract class FieldParser { { required: this.configData.mandatoryMessage }); } + protected addNotRepeatableValidator(controlModel) { + controlModel.validators = Object.assign({}, controlModel.validators, { notRepeatable: null }); + controlModel.errorMessages = Object.assign( + {}, + controlModel.errorMessages, + { + notRepeatable: 'error.validation.not.repeatable', + }, + ); + } + protected setLabel(controlModel, label = true, labelEmpty = false) { if (label) { controlModel.label = (labelEmpty) ? ' ' : this.configData.label; diff --git a/src/app/shared/form/form.service.ts b/src/app/shared/form/form.service.ts index 11fe5c54de3..1def00fd5bb 100644 --- a/src/app/shared/form/form.service.ts +++ b/src/app/shared/form/form.service.ts @@ -217,7 +217,7 @@ export class FormService { }); } - field.markAsUntouched(); + field.markAsUntouched({ onlySelf: true }); } public resetForm(formGroup: UntypedFormGroup, groupModel: DynamicFormControlModel[], formId: string) { diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 75950bfe526..2e99888bf86 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -275,17 +275,23 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { } }); - const diffResult = []; - // compare current form data state with section data retrieved from store - const diffObj = difference(sectionDataToCheck, this.formData); + const diffFromObj = this.hasDifferences(sectionDataToCheck, this.formData); + const diffToObj = this.hasDifferences(this.formData, sectionDataToCheck); + + return diffFromObj || diffToObj; + } + + private hasDifferences(object1: object, object2: object) { + const diffResult = []; + const diffObj = difference(object1, object2); // iterate over differences to check whether they are actually different Object.keys(diffObj) .forEach((key) => { diffObj[key].forEach((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) { + if (value.hasOwnProperty('value') && findIndex(object2[key], { value: value.value }) < 0) { diffResult.push(value); } }); @@ -298,6 +304,9 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * @private */ private inCurrentSubmissionScope(field: string): boolean { + if (isNotEmpty(this.sectionMetadata) && !this.sectionMetadata.includes(field)) { + return false; + } const scope = this.formConfig?.rows.find((row: FormRowModel) => { if (row.fields?.[0]?.selectableMetadata) { return row.fields?.[0]?.selectableMetadata?.[0]?.metadata === field; diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index cec60a9cdf3..4c2a4af4c29 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -522,6 +522,9 @@ export class SectionsService { rawData.rows.forEach((currentRow) => { if (currentRow.fields && !isEmpty(currentRow.fields)) { currentRow.fields.forEach((field) => { + if (field.selectableRelationship) { + metadata.push(`relation.${field.selectableRelationship.relationshipType}`); + } if (field.selectableMetadata && !isEmpty(field.selectableMetadata)) { field.selectableMetadata.forEach((selectableMetadata) => { if (!metadata.includes(selectableMetadata.metadata)) { diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 56fd637ff3f..a4e64885a58 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1910,6 +1910,8 @@ "error.validation.required": "This field is required", + "error.validation.not.repeatable": "This field only accepts one value. Please discard the additional ones.", + "error.validation.NotValidEmail": "This is not a valid email", "error.validation.emailTaken": "This email is already taken",