From 5ef6419ed52c8ec3a37bc0466f54b46faf304250 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 17 Apr 2025 17:37:12 +0200 Subject: [PATCH 1/8] 129946: Submission non-repeatable fields validation --- .../form/builder/parsers/field-parser.ts | 40 ++++++++++++++++++- src/app/shared/form/form.module.ts | 7 +++- src/assets/i18n/en.json5 | 2 + 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 7ea55d44549..db6d5595406 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -25,6 +25,7 @@ import { VocabularyOptions } from '../../../../core/submission/vocabularies/mode import { ParserType } from './parser-type'; import { isNgbDateStruct } from '../../../date.util'; import { SubmissionScopeType } from '../../../../core/submission/submission-scope-type'; +import { AbstractControl, ValidatorFn } from '@angular/forms'; export const SUBMISSION_ID: InjectionToken = new InjectionToken('submissionId'); export const CONFIG_DATA: InjectionToken = new InjectionToken('configData'); @@ -37,6 +38,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; @@ -119,7 +142,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()); @@ -411,6 +438,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.module.ts b/src/app/shared/form/form.module.ts index 792de6f2518..121534ff107 100644 --- a/src/app/shared/form/form.module.ts +++ b/src/app/shared/form/form.module.ts @@ -21,7 +21,7 @@ import { DsDynamicLookupRelationExternalSourceTabComponent } from './builder/ds- import { SharedModule } from '../shared.module'; import { TranslateModule } from '@ngx-translate/core'; import { SearchModule } from '../search/search.module'; -import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormService, DynamicFormValidationService, DYNAMIC_VALIDATORS } from '@ng-dynamic-forms/core'; import { ExistingMetadataListElementComponent } from './builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; import { ExistingRelationListElementComponent } from './builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component'; import { ExternalSourceEntryImportModalComponent } from './builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component'; @@ -42,6 +42,7 @@ import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-boots import { CdkTreeModule } from '@angular/cdk/tree'; import { ThemedDynamicLookupRelationSearchTabComponent } from './builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/themed-dynamic-lookup-relation-search-tab.component'; import { ThemedDynamicLookupRelationExternalSourceTabComponent } from './builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/themed-dynamic-lookup-relation-external-source-tab.component'; +import { CUSTOM_VALIDATORS } from './builder/parsers/field-parser'; const COMPONENTS = [ CustomSwitchComponent, @@ -106,6 +107,10 @@ const DIRECTIVES = [ provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { + provide: DYNAMIC_VALIDATORS, + useValue: CUSTOM_VALIDATORS, + }, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 6c91bae4c16..b6537a4e768 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1592,6 +1592,8 @@ "error.validation.required": "This field is required", + "error.validation.not.repeatable": "This field is not repeatable", + "error.validation.NotValidEmail": "This E-mail is not a valid email", "error.validation.emailTaken": "This E-mail is already taken", From 982d8e950592f7cd14625f4f089140af5057b18d Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 23 Apr 2025 15:58:27 +0200 Subject: [PATCH 2/8] 129946: Review 18/04/2025 - fix missing errors --- src/app/submission/sections/form/section-form.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 2a07f7e3f17..3675638863f 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -267,6 +267,9 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * @private */ private inCurrentSubmissionScope(field: string): boolean { + if (!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; From 7755dbdaec63be44fcb3562975e1554e8bbf324a Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 29 Apr 2025 13:18:33 +0200 Subject: [PATCH 3/8] 129946: message update --- src/assets/i18n/en.json5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b6537a4e768..b0d0c48fca8 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1592,7 +1592,7 @@ "error.validation.required": "This field is required", - "error.validation.not.repeatable": "This field is not repeatable", + "error.validation.not.repeatable": "This field only accepts one value. Please discard the additional ones.", "error.validation.NotValidEmail": "This E-mail is not a valid email", From de7587e70431eafb9d8a0f0adf66482a56b83188 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 29 Apr 2025 14:20:56 +0200 Subject: [PATCH 4/8] 129946: undefined check --- src/app/submission/sections/form/section-form.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 3675638863f..3fd8aa8c0d0 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -267,7 +267,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * @private */ private inCurrentSubmissionScope(field: string): boolean { - if (!this.sectionMetadata.includes(field)) { + if (isNotEmpty(this.sectionMetadata) && !this.sectionMetadata.includes(field)) { return false; } const scope = this.formConfig?.rows.find((row: FormRowModel) => { From 91a019bd8b5a3a9c40f58f09bb1a8d17330c56e7 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 29 Apr 2025 14:56:55 +0200 Subject: [PATCH 5/8] 129946: lint fix --- src/app/init.service.ts | 7 +++++-- src/app/shared/form/builder/parsers/field-parser.ts | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/init.service.ts b/src/app/init.service.ts index 1916ac3e4d5..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, DYNAMIC_VALIDATORS } from '@ng-dynamic-forms/core'; +import { + DYNAMIC_FORM_CONTROL_MAP_FN, + DYNAMIC_VALIDATORS, +} from '@ng-dynamic-forms/core'; import { select, Store, @@ -41,11 +44,11 @@ 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'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; -import { CUSTOM_VALIDATORS } from './shared/form/builder/parsers/field-parser'; /** * Performs the initialization of the app. diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index c558ea46d0a..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, @@ -36,7 +40,6 @@ import { VisibilityType } from './../../../../submission/sections/visibility-typ import { setLayout } from './parser.utils'; import { ParserOptions } from './parser-options'; import { ParserType } from './parser-type'; -import { AbstractControl, ValidatorFn } from '@angular/forms'; export const SUBMISSION_ID: InjectionToken = new InjectionToken('submissionId'); export const CONFIG_DATA: InjectionToken = new InjectionToken('configData'); From b9468ab4872c80db4d52d0e2b020c77788843410 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 20 May 2025 17:18:48 +0200 Subject: [PATCH 6/8] 129946: Prevent non-repeatable error message from being hidden --- .../dynamic-scrollable-dropdown.component.ts | 6 +++++- src/app/shared/form/form.service.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) 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 a4ca2101934..cee3953ee59 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 @@ -93,7 +93,6 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom */ openDropdown(sdRef: NgbDropdown) { if (!this.model.readOnly) { - this.group.markAsUntouched(); sdRef.open(); } } @@ -183,4 +182,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/form.service.ts b/src/app/shared/form/form.service.ts index bf316daaed3..d611c60b4c2 100644 --- a/src/app/shared/form/form.service.ts +++ b/src/app/shared/form/form.service.ts @@ -191,7 +191,7 @@ export class FormService { }); } - field.markAsUntouched(); + field.markAsUntouched({ onlySelf: true }); } public resetForm(formGroup: UntypedFormGroup, groupModel: DynamicFormControlModel[], formId: string) { From 6a500a508cf16ea864a3054ee0f4fd0cad4c85ca Mon Sep 17 00:00:00 2001 From: lotte Date: Tue, 17 Jun 2025 12:55:38 +0200 Subject: [PATCH 7/8] 129946: fixed bug for removing/adding relationships without linked metadata (cherry picked from commit f68d7fd83ecbd8e48119e65d7732d7516eb42084) --- .../ds-dynamic-form-control-container.component.ts | 2 +- .../sections/form/section-form.component.ts | 14 ++++++++++---- src/app/submission/sections/sections.service.ts | 1 + 3 files changed, 12 insertions(+), 5 deletions(-) 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 ff5a119b6fc..b681f682e1d 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 @@ -329,7 +329,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo true, true, ... itemLinksToFollow(this.fetchThumbnail)).pipe( - getAllSucceededRemoteData(), + getFirstSucceededRemoteData(), getRemoteDataPayload()); this.relationshipValue$ = observableCombineLatest([this.item$.pipe(take(1)), relationship$]).pipe( switchMap(([item, relationship]: [Item, Relationship]) => diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 3fd8aa8c0d0..aa19c9c749a 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -244,17 +244,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); } }); diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index 0ea62322370..66ae6380b22 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -503,6 +503,7 @@ export class SectionsService { 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)) { From 4086400ed647d929a943baf3cf617db6876321a9 Mon Sep 17 00:00:00 2001 From: lotte Date: Tue, 17 Jun 2025 16:32:10 +0200 Subject: [PATCH 8/8] 129946: Fix for adding relationships in submission without metadata (cherry picked from commit 9fb4950fee182c4ddd8f7294aeb3bcd1a0caaa09) --- src/app/submission/sections/sections.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index 66ae6380b22..14bad22aad6 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -503,7 +503,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)) {