From 6ca0a70e9c04d03f2c9d0ef8b3f1b1e19cdb21e6 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Thu, 29 Jan 2026 11:24:06 +0100 Subject: [PATCH 01/16] Remove section-level save on form field change Section-level save is no longer triggered on metadata change to prevent unnecessary change detection and scroll jumps, especially in Firefox. Data integrity is maintained by existing save mechanisms on section blur and form deactivation. --- .../sections/form/section-form.component.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 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 3b4af9f5ade..ea68a81ceb9 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -452,7 +452,20 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * @param value */ dispatchFormSaveAndReinitialize(metadata, value) { - this.submissionService.dispatchSaveSection(this.submissionId, this.sectionData.id); + // NOTE: Section-level save (dispatchSaveSection) is intentionally NOT used here. + // + // Why section-level save is excluded from onChange: + // - SaveSubmissionSectionFormAction returns ALL sections data from backend (not just the target section) + // - parseSaveResponse() then dispatches UpdateSectionDataAction for every section + // - This cascades change detection across unrelated sections simultaneously (multiple detectChanges() calls) + // - In Firefox, this synchronized DOM update causes unexpected scroll jumps to License section + // + // Why it's safe to exclude: + // - Sponsor/Author metadata values are already persisted via: + // * Full form save on section blur (sections.directive.ts line 140) + // * SaveSubmissionFormAction covers all metadata when sections deactivate + // - Data integrity is guaranteed through established save flows + // - Section-level save on onChange is redundant and harmful for UX this.reinitializeForm(metadata, value); } From 67367fc3c161d8d57754958fc01b63a906f836b0 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Thu, 29 Jan 2026 11:57:10 +0100 Subject: [PATCH 02/16] Remove redundant save section dispatch test Eliminated the expectation for dispatchSaveSection call in the onChange test, focusing the test on form reinitialization behavior. --- src/app/submission/sections/form/section-form.component.spec.ts | 2 -- 1 file changed, 2 deletions(-) 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 07a81ef0073..e4fa3cc09d0 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -686,8 +686,6 @@ describe('SubmissionSectionFormComponent test suite', () => { comp.onChange(dynamicFormControlEvent); fixture.detectChanges(); - expect(submissionServiceStub.dispatchSaveSection).toHaveBeenCalled(); - expect(comp.reinitializeForm).toHaveBeenCalled(); }); }); From f0660051e684b407da0d7fa3126027a48a246cb9 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Wed, 4 Feb 2026 14:10:37 +0100 Subject: [PATCH 03/16] fixing the scroll fixing the scroll --- .../form/section-form.component.spec.ts | 4 ++-- .../sections/form/section-form.component.ts | 21 +++++++------------ 2 files changed, 9 insertions(+), 16 deletions(-) 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 e4fa3cc09d0..fe506ab0034 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -663,7 +663,7 @@ describe('SubmissionSectionFormComponent test suite', () => { compAsAny = null; }); - it('onChange on `local.sponsor` complex input field should refresh formModel', () => { + it('onChange on `local.sponsor` complex input field should skip reinitialize', () => { const sectionData = {}; formOperationsService.getFieldPathSegmentedFromChangeEvent.and.returnValue('local.sponsor'); formOperationsService.getFieldValueFromChangeEvent.and.returnValue({ value: EU_SPONSOR }); @@ -686,7 +686,7 @@ describe('SubmissionSectionFormComponent test suite', () => { comp.onChange(dynamicFormControlEvent); fixture.detectChanges(); - expect(comp.reinitializeForm).toHaveBeenCalled(); + expect(comp.reinitializeForm).not.toHaveBeenCalled(); }); }); }); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index ea68a81ceb9..18b31be1523 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -349,6 +349,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { if (isNotEmpty(sectionData) && !isEqual(sectionData, this.sectionData.data)) { this.sectionData.data = sectionData; if (this.hasMetadataEnrichment(sectionData)) { + const scrollPosition = window.pageYOffset || document.documentElement.scrollTop; this.isUpdating = true; this.formModel = null; this.cdr.detectChanges(); @@ -356,6 +357,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.checksForErrors(errors); this.isUpdating = false; this.cdr.detectChanges(); + window.scrollTo(0, scrollPosition); } else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errorsToShow)) { this.checksForErrors(errors); } @@ -452,20 +454,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * @param value */ dispatchFormSaveAndReinitialize(metadata, value) { - // NOTE: Section-level save (dispatchSaveSection) is intentionally NOT used here. - // - // Why section-level save is excluded from onChange: - // - SaveSubmissionSectionFormAction returns ALL sections data from backend (not just the target section) - // - parseSaveResponse() then dispatches UpdateSectionDataAction for every section - // - This cascades change detection across unrelated sections simultaneously (multiple detectChanges() calls) - // - In Firefox, this synchronized DOM update causes unexpected scroll jumps to License section - // - // Why it's safe to exclude: - // - Sponsor/Author metadata values are already persisted via: - // * Full form save on section blur (sections.directive.ts line 140) - // * SaveSubmissionFormAction covers all metadata when sections deactivate - // - Data integrity is guaranteed through established save flows - // - Section-level save on onChange is redundant and harmful for UX + this.submissionService.dispatchSaveSection(this.submissionId, this.sectionData.id); this.reinitializeForm(metadata, value); } @@ -505,9 +494,13 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { // @ts-ignore if (metadataValueFromDB[index].value === newMetadataValue.value) { // update form + // Preserve scroll position to prevent unwanted scroll behavior + const scrollPosition = window.pageYOffset || document.documentElement.scrollTop; this.formModel = undefined; this.cdr.detectChanges(); this.ngOnInit(); + // Restore scroll position after form rebuild + window.scrollTo(0, scrollPosition); clearInterval(interval); this.isUpdating = false; } From 2418bb41d31217b8c3488e52fef445967155e6c2 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Mon, 9 Feb 2026 16:37:35 +0100 Subject: [PATCH 04/16] Support hide/show for single-item array groups Add visual-empty support for single-item array groups so the first (structural) group can be hidden and revealed without adding/removing items. Key changes: - DynamicRowArrayModel: add serializable properties hideGroupsWhenEmpty and allowDeleteOnSingleItem and read them from config. - Templates & styles: add ds-form-array-group-hidden CSS class and template logic to hide the first group and show an empty-state "Add" button. Add i18n key `form.add-single`. - DsDynamicFormArrayComponent: add shouldHideGroup and isGroupEmpty helpers to determine visual-empty state. - FormComponent: add revealFirstGroup, clearItemValues, handleItemDelete, shouldShowDeleteButton, shouldShowEmptyStateAddButton and isFirstGroupEmpty to handle reveal/clear flows and button visibility; wire delete button to route to those behaviors. - SectionFormOperationsService: update remove dispatch to handle clear-last-item vs real remove so JSON Patch ops are correct when clearing the only group and when stored values exist. - FieldParser: set hideGroupsWhenEmpty/allowDeleteOnSingleItem for sponsor complex inputs and fix getInitArrayIndex logic to ensure at least one initial group. - Sponsor dropdown: remove a now-unnecessary case branch. - SubmissionSectionFormComponent: minor type/ts-ignore fixes and updated test expectations to reflect the new sponsor reinitialize/save behavior. These updates provide a symmetric UX where the first array group can be visually hidden when empty, revealed without creating a new item, and cleared (not removed) to return to the visual-empty state while preserving correct patch operations. --- .../dynamic-form-array.component.html | 5 + .../dynamic-form-array.component.scss | 4 + .../dynamic-form-array.component.ts | 74 +++++++ .../models/ds-dynamic-row-array-model.ts | 10 + ...c-sponsor-scrollable-dropdown.component.ts | 6 - .../form/builder/parsers/field-parser.ts | 30 ++- src/app/shared/form/form.component.html | 18 +- src/app/shared/form/form.component.ts | 197 ++++++++++++++++++ .../form/section-form-operations.service.ts | 36 +++- .../form/section-form.component.spec.ts | 7 +- .../sections/form/section-form.component.ts | 23 +- src/assets/i18n/cs.json5 | 2 + src/assets/i18n/en.json5 | 2 + 13 files changed, 380 insertions(+), 34 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html index dd19e6158df..b374561cdc6 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html @@ -11,6 +11,7 @@ role="group" [formGroupName]="idx" [ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]" + [class.ds-form-array-group-hidden]="shouldHideGroup(idx)" cdkDrag [cdkDragDisabled]="dragDisabled" [cdkDragPreviewClass]="'ds-submission-reorder-dragging'" @@ -40,6 +41,10 @@ +
+ +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss index ce4a89226ad..f4f6b3bfd67 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss @@ -63,3 +63,7 @@ .cdk-drag-placeholder { opacity: 0; } + +.ds-form-array-group-hidden { + display: none !important; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts index 9d48bdac216..5c090f925ef 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts @@ -81,4 +81,78 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent { get dragDisabled(): boolean { return this.model.groups.length === 1 || !this.model.isDraggable; } + + /** + * Determines whether a group should be hidden (CSS display:none) in visual-empty state. + * Hides the group when hideGroupsWhenEmpty flag is set, it's the first group, it's the only group, and it's empty. + * + * @param index The group index to check + * @returns true if the group should be hidden + */ + shouldHideGroup(index: number): boolean { + const hideFlag = this.model.hideGroupsWhenEmpty; + const isFirstGroup = index === 0; + const isSingleGroup = this.model.groups.length === 1; + const isEmpty = this.isGroupEmpty(index); + + const shouldHide = hideFlag && isFirstGroup && isSingleGroup && isEmpty; + + return shouldHide; + } + + /** + * Check if a specific group in the array is visually empty (all controls have no meaningful values). + * Used to determine visual-empty state for conditional hiding. + * + * @param index The group index to check + * @returns true if the group at the given index has all empty controls + */ + isGroupEmpty(index: number): boolean { + const formArray = this.control as any; + if (!formArray || !formArray.length || index >= formArray.length) { + return false; + } + + const groupControl = formArray.at(index); + if (!groupControl || typeof groupControl.value !== 'object') { + return false; + } + + const values = groupControl.value; + if (!values) { + return true; + } + + const keys = Object.keys(values); + for (const key of keys) { + const value = values[key]; + if (hasValue(value)) { + if (typeof value === 'string' && value.trim() !== '') { + return false; + } else if (typeof value === 'object' && value !== null) { + if (Array.isArray(value) && value.length > 0) { + return false; + } else if (value.hasOwnProperty('value') && value.value && value.value !== '') { + return false; + } else { + const objKeys = Object.keys(value); + const hasNonEmptyProp = objKeys.some(objKey => { + const propValue = value[objKey]; + const isNonEmpty = propValue !== null && + propValue !== undefined && + propValue !== '' && + !(Array.isArray(propValue) && propValue.length === 0); + return isNonEmpty; + }); + if (hasNonEmptyProp) { + return false; + } + } + } else if (typeof value === 'number' || typeof value === 'boolean') { + return false; + } + } + } + return true; + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts index 52364df45e3..48d4f968aa9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts @@ -18,6 +18,8 @@ export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig hasSelectableMetadata: boolean; isDraggable: boolean; showButtons: boolean; + hideGroupsWhenEmpty?: boolean; + allowDeleteOnSingleItem?: boolean; typeBindRelations?: DynamicFormControlRelation[]; isInlineGroupArray?: boolean; } @@ -32,6 +34,8 @@ export class DynamicRowArrayModel extends DynamicFormArrayModel { @serializable() hasSelectableMetadata: boolean; @serializable() isDraggable: boolean; @serializable() showButtons = true; + @serializable() hideGroupsWhenEmpty = false; + @serializable() allowDeleteOnSingleItem = false; @serializable() typeBindRelations: DynamicFormControlRelation[]; isRowArray = true; isInlineGroupArray = false; @@ -47,6 +51,12 @@ export class DynamicRowArrayModel extends DynamicFormArrayModel { if (hasValue(config.showButtons)) { this.showButtons = config.showButtons; } + if (hasValue(config.hideGroupsWhenEmpty)) { + this.hideGroupsWhenEmpty = config.hideGroupsWhenEmpty; + } + if (hasValue(config.allowDeleteOnSingleItem)) { + this.allowDeleteOnSingleItem = config.allowDeleteOnSingleItem; + } this.submissionId = config.submissionId; this.relationshipConfig = config.relationshipConfig; this.metadataKey = config.metadataKey; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component.ts index 37b2fcf34f2..94495f8b9f2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component.ts @@ -110,12 +110,6 @@ export class DsDynamicSponsorScrollableDropdownComponent extends DsDynamicScroll case DYNAMIC_INPUT_TYPE: (input as DsDynamicInputModel).value = ''; break; - case DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN: - // Remove it only if the funding type is `N/A` - if (this.fundingTypeIsNotApplicable(fundingTypeValue)) { - (input as DynamicScrollableDropdownModel).value = ''; - } - break; default: break; } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 0ea016d15b2..a7c1600a6af 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -86,6 +86,10 @@ export abstract class FieldParser { metadataFields: this.getAllFieldIds(), hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata), isDraggable, + hideGroupsWhenEmpty: this.configData.input.type === ParserType.Complex && + metadataKey === 'local.sponsor', + allowDeleteOnSingleItem: this.configData.input.type === ParserType.Complex && + metadataKey === 'local.sponsor', typeBindRelations: isNotEmpty(this.configData.typeBind) ? this.getTypeBindRelations(this.configData.typeBind, this.parserOptions.typeField) : null, groupFactory: () => { @@ -244,18 +248,32 @@ export abstract class FieldParser { protected getInitArrayIndex() { const fieldIds: any = this.getAllFieldIds(); - if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length === 1 && this.initFormValues.hasOwnProperty(fieldIds)) { - return this.initFormValues[fieldIds].length; - } else if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length > 1) { + + if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length === 1) { + if (this.initFormValues.hasOwnProperty(fieldIds[0])) { + const count = this.initFormValues[fieldIds[0]].length; + const result = count === 0 ? 1 : count; + return result; + } else { + const result = 1; + return result; + } + } + + else if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length > 1) { let counter = 0; fieldIds.forEach((id) => { if (this.initFormValues.hasOwnProperty(id)) { counter = counter + this.initFormValues[id].length; } }); - return (counter === 0) ? 1 : counter; - } else { - return 1; + const result = counter === 0 ? 1 : counter; + return result; + } + + else { + const result = 1; + return result; } } diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 1b27c9d308d..a2d8f065645 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -13,16 +13,16 @@ (ngbEvent)="onCustomEvent($event)"> -
-
+
+
+
+ +
+
+
diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 951d651d5f0..1e5544b9d3e 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -334,12 +334,209 @@ export class FormComponent implements OnDestroy, OnInit { this.formService.changeForm(this.formId, this.formModel); } + /** + * Reveal the hidden first group without adding a new group. + * Used when hideGroupsWhenEmpty flag is set and user clicks "Add" in empty-state. + * Instead of creating a new group, this just sets hideGroupsWhenEmpty to false + * to unhide the structurally-required but visually-hidden Group 0. + * + * @param $event The click event + * @param arrayContext The array model context + */ + revealFirstGroup($event: any, arrayContext: DynamicFormArrayModel): void { + // Toggle off the hideGroupsWhenEmpty flag to reveal Group 0 + (arrayContext as any).hideGroupsWhenEmpty = false; + + this.formService.changeForm(this.formId, this.formModel); + } + + /** + * Clear all values in a single-item array and return to visual-empty state. + * Used for hideGroupsWhenEmpty arrays where delete should hide the group instead of removing it. + * This provides symmetric behavior: "Add" reveals → "Delete" hides and clears. + * + * @param $event The click event + * @param arrayContext The array model context + * @param index The index of the group to clear + */ + clearItemValues($event: any, arrayContext: DynamicFormArrayModel, index: number): void { + const metadataKey = (arrayContext as any).metadataKey; + const isSponsor = metadataKey === 'local.sponsor'; + + // Get the form control BEFORE emitting/resetting to log values + const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as UntypedFormArray; + const groupControl = formArrayControl.at(index); + + // Emit remove event before reset so submission patch ops are created for cleared values + const removeEvent = this.getEvent($event, arrayContext, index, 'remove'); + + // Mark this as a "clear last item" operation (not a regular multi-item delete) + // This flag distinguishes: delete last item (→ REMOVE field) vs delete one of many (→ ADD updated array) + (removeEvent as any).isClearLastItem = true; + + this.removeArrayItem.emit(removeEvent); + + if (groupControl) { + groupControl.reset(); // Clears all form controls in the group + + // CRITICAL: reset() doesn't mark form as dirty, but we need to save the "cleared" state + // Mark controls as dirty so formService.changeForm() creates patch operations + groupControl.markAsDirty(); + groupControl.markAsTouched(); + formArrayControl.markAsDirty(); + this.formGroup.markAsDirty(); + } + + // Re-enable visual-empty state to hide fields and show "Add" button + (arrayContext as any).hideGroupsWhenEmpty = true; + + // Notify form service of change so save button enables + this.formService.changeForm(this.formId, this.formModel); + } + + /** + * Route delete action: for single-item hideGroupsWhenEmpty arrays, clear instead of remove. + * For multi-item arrays, perform standard removal. + * + * @param $event The click event + * @param arrayContext The array model context + * @param index The index of the group to delete + */ + handleItemDelete($event: any, arrayContext: DynamicFormArrayModel, index: number): void { + const hideWhenEmpty = (arrayContext as any).hideGroupsWhenEmpty; + const isSingleGroup = arrayContext.groups.length === 1; + const shouldClear = hideWhenEmpty && isSingleGroup; + + // For single-item arrays with hideGroupsWhenEmpty: clear instead of remove + if (shouldClear) { + this.clearItemValues($event, arrayContext, index); + } else { + // For multi-item arrays: standard removal + this.removeItem($event, arrayContext, index); + } + } + isVirtual(arrayContext: DynamicFormArrayModel, index: number) { const context = arrayContext.groups[index]; const value: FormFieldMetadataValueObject = (context.group[0] as any).metadataValue; return isNotEmpty(value) && value.isVirtual; } + /** + * Determines whether the delete button should be displayed for an array item. + * Shows delete button when multiple groups exist, or for single-item arrays with allowDeleteOnSingleItem enabled (unless in visual-empty state). + * + * @param arrayContext The array model context + * @param index The index of the item + * @returns true if delete button should be visible + */ + shouldShowDeleteButton(arrayContext: DynamicFormArrayModel, index: number): boolean { + const notRepeatable = (arrayContext as any).notRepeatable; + const isVirtualItem = this.isVirtual(arrayContext, index); + const isReadOnly = this.isItemReadOnly(arrayContext, index); + const multipleGroups = arrayContext.groups.length > 1; + const allowDeleteSingle = (arrayContext as any).allowDeleteOnSingleItem; + const hideWhenEmpty = (arrayContext as any).hideGroupsWhenEmpty; + const singleGroup = arrayContext.groups.length === 1; + const isEmpty = this.isFirstGroupEmpty(arrayContext); + const inVisualEmptyState = hideWhenEmpty && singleGroup && isEmpty; + + const shouldShow = !notRepeatable && !isVirtualItem && !isReadOnly && + (multipleGroups || (allowDeleteSingle && !inVisualEmptyState)); + + return shouldShow; + } + + /** + * Determines whether the "Add" button should be displayed in empty-state. + * Shows button only when hideGroupsWhenEmpty is enabled, field is single-item and empty, at first index, and not read-only. + * + * @param arrayContext The array model context + * @param index The index of the item + * @returns true if empty-state Add button should be visible + */ + shouldShowEmptyStateAddButton(arrayContext: DynamicFormArrayModel, index: number): boolean { + const notRepeatable = (arrayContext as any).notRepeatable; + const hideWhenEmpty = (arrayContext as any).hideGroupsWhenEmpty; + const singleGroup = arrayContext.groups.length === 1; + const isFirstIndex = index === 0; + const isEmpty = this.isFirstGroupEmpty(arrayContext); + const isReadOnly = this.isItemReadOnly(arrayContext, index); + + const shouldShow = !notRepeatable && hideWhenEmpty && singleGroup && isFirstIndex && isEmpty && !isReadOnly; + + return shouldShow; + } + + /** + * Check if the first group in an array is visually empty (all controls have no meaningful values). + * This is used to determine visual-empty state independent of structural state (FormArray always has 1 group minimum). + * + * @param arrayContext The array model context + * @returns true if first group exists and all its controls are empty/null + */ + isFirstGroupEmpty(arrayContext: DynamicFormArrayModel): boolean { + // Must have at least one group + if (!arrayContext.groups || arrayContext.groups.length === 0) { + return false; + } + + // Get the FormArray control + const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as UntypedFormArray; + if (!formArrayControl || formArrayControl.length === 0) { + return false; + } + + // Get first group's FormGroup + const firstGroupControl = formArrayControl.at(0) as UntypedFormGroup; + if (!firstGroupControl || !firstGroupControl.controls) { + return false; + } + + // Check all controls in the first group - if ANY has a value, not empty + const controlNames = Object.keys(firstGroupControl.controls); + + for (const controlName of controlNames) { + const control = firstGroupControl.get(controlName); + if (control) { + const value = control.value; + // Check for non-empty values (handle strings, objects, arrays) + if (hasValue(value)) { + if (typeof value === 'string' && value.trim() !== '') { + return false; // Has string value + } else if (typeof value === 'object' && value !== null) { + // Check if object has meaningful properties + if (Array.isArray(value)) { + if (value.length > 0) { + return false; // Has array values + } + } else if (value.hasOwnProperty('value') && value.value && value.value !== '') { + return false; // Has FormFieldMetadataValueObject with value + } else { + // Check if object has any non-null properties + const objKeys = Object.keys(value); + const hasNonNullProperty = objKeys.some(key => { + const propValue = value[key]; + // Check if value is non-empty (excluding empty arrays) + const isNonNull = propValue !== null && + propValue !== undefined && + propValue !== '' && + !(Array.isArray(propValue) && propValue.length === 0); + return isNonNull; + }); + if (hasNonNullProperty) { + return false; + } + } + } else if (typeof value === 'number' || typeof value === 'boolean') { + return false; // Has numeric or boolean value + } + } + } + } + return true; // All controls are empty + } + protected getEvent($event: any, arrayContext: DynamicFormArrayModel, index: number, type: string): DynamicFormControlEvent { const context = arrayContext.groups[index]; const itemGroupModel = context.context; diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 778063dd316..57c8aef321c 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -60,9 +60,12 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject, hasStoredValue: boolean): void { + const fieldId = this.formBuilder.getId(event.model); + const isSponsor = fieldId === 'local.sponsor'; + switch (event.type) { case 'remove': - this.dispatchOperationsFromRemoveEvent(pathCombiner, event, previousValue); + this.dispatchOperationsFromRemoveEvent(pathCombiner, event, previousValue, hasStoredValue); break; case 'change': this.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, hasStoredValue); @@ -294,18 +297,25 @@ export class SectionFormOperationsService { * the [[DynamicFormControlEvent]] for the specified operation * @param previousValue * the [[FormFieldPreviousValueObject]] for the specified operation + * @param hasStoredValue + * representing if field value related to the specified operation has stored value */ protected dispatchOperationsFromRemoveEvent(pathCombiner: JsonPatchOperationPathCombiner, event: DynamicFormControlEvent, - previousValue: FormFieldPreviousValueObject): void { + previousValue: FormFieldPreviousValueObject, + hasStoredValue: boolean): void { + + const fieldId = this.formBuilder.getId(event.model); + const isSponsor = fieldId === 'local.sponsor'; const path = this.getFieldPathFromEvent(event); const value = this.getFieldValueFromChangeEvent(event); + + if (this.formBuilder.isQualdropGroup(event.model as DynamicFormControlModel)) { this.dispatchOperationsFromMap(this.getQualdropValueMap(event), pathCombiner, event, previousValue); } else if (event.context && event.context instanceof DynamicFormArrayGroupModel) { - // Model is a DynamicRowArrayModel - this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); + this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue, true, hasStoredValue); } else if ((isNotEmpty(value) && typeof value === 'string') || (isNotEmpty(value) && value instanceof FormFieldMetadataValueObject && value.hasValue())) { this.operationsBuilder.remove(pathCombiner.getPath(path)); } @@ -496,7 +506,7 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject) { - return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel, previousValue); + return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel, previousValue, false, false); } /** @@ -514,11 +524,22 @@ export class SectionFormOperationsService { private handleArrayGroupPatch(pathCombiner: JsonPatchOperationPathCombiner, event, model: DynamicRowArrayModel, - previousValue: FormFieldPreviousValueObject) { + previousValue: FormFieldPreviousValueObject, + isRemoveEvent: boolean = false, + hasStoredValue: boolean = false) { + const fieldId = model.metadataKey || this.formBuilder.getId(event.model); + const isSponsor = fieldId === 'local.sponsor'; const arrayValue = this.formBuilder.getValueFromModel([model]); const segmentedPath = this.getFieldPathSegmentedFromChangeEvent(event); - if (isNotEmpty(arrayValue)) { + + const isClearLastItem = (event as any).isClearLastItem === true; + + const shouldRemoveField = isRemoveEvent && hasStoredValue && isClearLastItem + + if (shouldRemoveField) { + this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); + } else if (isNotEmpty(arrayValue)) { this.operationsBuilder.add( pathCombiner.getPath(segmentedPath), arrayValue[segmentedPath], @@ -527,6 +548,5 @@ export class SectionFormOperationsService { } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model))) { this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); } - } } 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 fe506ab0034..5cc04348009 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -663,7 +663,7 @@ describe('SubmissionSectionFormComponent test suite', () => { compAsAny = null; }); - it('onChange on `local.sponsor` complex input field should skip reinitialize', () => { + it('onChange on `local.sponsor` complex input field should trigger reinitialize', () => { const sectionData = {}; formOperationsService.getFieldPathSegmentedFromChangeEvent.and.returnValue('local.sponsor'); formOperationsService.getFieldValueFromChangeEvent.and.returnValue({ value: EU_SPONSOR }); @@ -677,6 +677,7 @@ describe('SubmissionSectionFormComponent test suite', () => { spyOn(comp, 'initForm'); spyOn(comp, 'subscriptions'); spyOn(comp, 'reinitializeForm'); + spyOn(submissionServiceStub, 'dispatchSaveSection'); const wi = new WorkspaceItem(); wi.item = createSuccessfulRemoteDataObject$(mockItemWithMetadataFieldsAndValue(['local.sponsor'], EU_SPONSOR)); @@ -686,7 +687,9 @@ describe('SubmissionSectionFormComponent test suite', () => { comp.onChange(dynamicFormControlEvent); fixture.detectChanges(); - expect(comp.reinitializeForm).not.toHaveBeenCalled(); + // The onChange method calls dispatchFormSaveAndReinitialize for sponsor fields + expect(submissionServiceStub.dispatchSaveSection).toHaveBeenCalledWith(comp.submissionId, comp.sectionData.id); + expect(comp.reinitializeForm).toHaveBeenCalledWith('local.sponsor', { value: EU_SPONSOR }); }); }); }); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 18b31be1523..dbf35746928 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -191,7 +191,8 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { 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), + // @ts-ignore - Type mismatch between ConfigObject and SubmissionFormsModel (pre-existing) + map((configData: RemoteData) => configData.payload), tap((config: SubmissionFormsModel) => this.formConfig = config), mergeMap(() => observableCombineLatest([ @@ -202,13 +203,14 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.sectionService.isSectionReadOnly(this.submissionId, this.sectionData.id, this.submissionService.getSubmissionScope()) ])), take(1)) - .subscribe(([sectionData, submissionObject, isSectionReadOnly]: [WorkspaceitemSectionFormObject, SubmissionObject, boolean]) => { + // @ts-ignore - Type union complexity with WorkspaceitemSectionDataType (pre-existing) + .subscribe(([sectionData, submissionObject, isSectionReadOnly]) => { if (isUndefined(this.formModel)) { // this.sectionData.errorsToShow = []; - this.submissionObject = submissionObject; - this.isSectionReadonly = isSectionReadOnly; + this.submissionObject = submissionObject as SubmissionObject; + this.isSectionReadonly = isSectionReadOnly as boolean; // Is the first loading so init form - this.initForm(sectionData); + this.initForm(sectionData as WorkspaceitemSectionFormObject); this.sectionData.data = sectionData; this.subscriptions(); this.isLoading = false; @@ -325,13 +327,15 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { ); const sectionMetadata = this.sectionService.computeSectionConfiguredMetadata(this.formConfig); this.sectionService.updateSectionData(this.submissionId, this.sectionData.id, sectionData, this.sectionData.errorsToShow, this.sectionData.serverValidationErrors, sectionMetadata); - } catch (e) { + } catch (e: any) { const msg: string = this.translate.instant('error.submission.sections.init-form-error') + e.toString(); const sectionError: SubmissionSectionError = { message: msg, path: '/sections/' + this.sectionData.id }; - console.error(e.stack); + // @ts-ignore - Unknown type doesn't have stack property (pre-existing) + const error = e as Error; + console.error(error?.stack || e); this.sectionService.setSectionError(this.submissionId, this.sectionData.id, sectionError); } } @@ -555,6 +559,8 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { onRemove(event: DynamicFormControlEvent): void { const fieldId = this.formBuilderService.getId(event.model); const fieldIndex = this.formOperationsService.getArrayIndexFromEvent(event); + const isSponsor = fieldId === 'local.sponsor'; + const hasStored = this.hasStoredValue(fieldId, fieldIndex); // Keep track that this field will be removed if (this.fieldsOnTheirWayToBeRemoved.has(fieldId)) { @@ -569,8 +575,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.pathCombiner, event, this.previousValue, - this.hasStoredValue(fieldId, fieldIndex)); - + hasStored); } /** diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index cc326adb60f..ea69f4bbe11 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -2218,6 +2218,8 @@ "forgot-password.form.submit": "Odeslat heslo", // "form.add": "Add more", "form.add": "Přidat další", + // "form.add-single": "Add", + "form.add-single": "Přidat", // "form.add-help": "Click here to add the current entry and to add another one", "form.add-help": "Klikněte zde pro přidání aktuálního záznamu a pro přidání dalšího", // "form.cancel": "Cancel", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b2a27c614a4..d691a236f1a 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1778,6 +1778,8 @@ "form.add": "Add more", + "form.add-single": "Add", + "form.add-help": "Click here to add the current entry and to add another one", "form.cancel": "Cancel", From 8d722a07316bffc3fbb70856ddfdf6ed77e027aa Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Mon, 9 Feb 2026 16:47:22 +0100 Subject: [PATCH 05/16] Clean up imports and formatting in form code Remove unused imports and apply small formatting fixes across form-related files to address lint warnings and improve consistency. Changes: - Remove unused DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN import from dynamic-sponsor-scrollable-dropdown.component.ts. - Remove unused ConfigObject import from section-form.component.ts. - Add missing semicolon in section-form-operations.service.ts. - Reformat conditional blocks in field-parser.ts (brace/whitespace cleanup) with no logic changes. No functional behavior was changed. --- .../dynamic-sponsor-scrollable-dropdown.component.ts | 1 - src/app/shared/form/builder/parsers/field-parser.ts | 8 ++------ .../sections/form/section-form-operations.service.ts | 2 +- .../submission/sections/form/section-form.component.ts | 1 - 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component.ts index 94495f8b9f2..7be6183dcfb 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component.ts @@ -9,7 +9,6 @@ import { VocabularyService } from '../../../../../../core/submission/vocabularie import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { DsDynamicScrollableDropdownComponent } from '../scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { - DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN, DynamicScrollableDropdownModel } from '../scrollable-dropdown/dynamic-scrollable-dropdown.model'; import { diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index a7c1600a6af..38d09460cca 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -258,9 +258,7 @@ export abstract class FieldParser { const result = 1; return result; } - } - - else if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length > 1) { + } else if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length > 1) { let counter = 0; fieldIds.forEach((id) => { if (this.initFormValues.hasOwnProperty(id)) { @@ -269,9 +267,7 @@ export abstract class FieldParser { }); const result = counter === 0 ? 1 : counter; return result; - } - - else { + } else { const result = 1; return result; } diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 57c8aef321c..1a9401de0ef 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -535,7 +535,7 @@ export class SectionFormOperationsService { const isClearLastItem = (event as any).isClearLastItem === true; - const shouldRemoveField = isRemoveEvent && hasStoredValue && isClearLastItem + const shouldRemoveField = isRemoveEvent && hasStoredValue && isClearLastItem; if (shouldRemoveField) { this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index dbf35746928..2bbe9aee8c9 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -32,7 +32,6 @@ import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { RequestService } from '../../../core/data/request.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { environment } from '../../../../environments/environment'; -import { ConfigObject } from '../../../core/config/models/config.model'; import { RemoteData } from '../../../core/data/remote-data'; import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; From 656dd874f3e5dd9cb7d07bf7ecb193776b38eda5 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 09:41:36 +0100 Subject: [PATCH 06/16] copilot suggestion for accessibility copilot suggestion for accessibility --- src/app/shared/form/form.component.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index a2d8f065645..445a84d3b9d 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -37,10 +37,10 @@ class="clearfix w-100">
From 2e8108b4e804da14a4b472d80f4b18d2e5b8921a Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 09:58:20 +0100 Subject: [PATCH 07/16] Extract form group empty check to util Add isFormGroupEmpty utility and update FormComponent to use it. This centralizes the logic for determining whether a FormGroup is visually empty (handling strings, arrays, nested objects, numbers/booleans), reduces duplication, and simplifies the component. Files changed: added src/app/shared/form/utils/form-group-empty.util.ts and modified src/app/shared/form/form.component.ts to import and delegate to the new utility. --- src/app/shared/form/form.component.ts | 48 ++------------ .../form/utils/form-group-empty.util.ts | 66 +++++++++++++++++++ 2 files changed, 71 insertions(+), 43 deletions(-) create mode 100644 src/app/shared/form/utils/form-group-empty.util.ts diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 1e5544b9d3e..e01106354e3 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -16,6 +16,7 @@ import findIndex from 'lodash/findIndex'; import { FormBuilderService } from './builder/form-builder.service'; import { hasValue, isNotEmpty, isNotNull, isNull } from '../empty.util'; import { FormService } from './form.service'; +import { isFormGroupEmpty } from './utils/form-group-empty.util'; import { FormEntry, FormError } from './form.reducer'; import { FormFieldMetadataValueObject } from './builder/models/form-field-metadata-value.model'; @@ -471,6 +472,7 @@ export class FormComponent implements OnDestroy, OnInit { /** * Check if the first group in an array is visually empty (all controls have no meaningful values). * This is used to determine visual-empty state independent of structural state (FormArray always has 1 group minimum). + * Delegates to shared utility function for consistent empty detection. * * @param arrayContext The array model context * @returns true if first group exists and all its controls are empty/null @@ -489,52 +491,12 @@ export class FormComponent implements OnDestroy, OnInit { // Get first group's FormGroup const firstGroupControl = formArrayControl.at(0) as UntypedFormGroup; - if (!firstGroupControl || !firstGroupControl.controls) { + if (!firstGroupControl) { return false; } - // Check all controls in the first group - if ANY has a value, not empty - const controlNames = Object.keys(firstGroupControl.controls); - - for (const controlName of controlNames) { - const control = firstGroupControl.get(controlName); - if (control) { - const value = control.value; - // Check for non-empty values (handle strings, objects, arrays) - if (hasValue(value)) { - if (typeof value === 'string' && value.trim() !== '') { - return false; // Has string value - } else if (typeof value === 'object' && value !== null) { - // Check if object has meaningful properties - if (Array.isArray(value)) { - if (value.length > 0) { - return false; // Has array values - } - } else if (value.hasOwnProperty('value') && value.value && value.value !== '') { - return false; // Has FormFieldMetadataValueObject with value - } else { - // Check if object has any non-null properties - const objKeys = Object.keys(value); - const hasNonNullProperty = objKeys.some(key => { - const propValue = value[key]; - // Check if value is non-empty (excluding empty arrays) - const isNonNull = propValue !== null && - propValue !== undefined && - propValue !== '' && - !(Array.isArray(propValue) && propValue.length === 0); - return isNonNull; - }); - if (hasNonNullProperty) { - return false; - } - } - } else if (typeof value === 'number' || typeof value === 'boolean') { - return false; // Has numeric or boolean value - } - } - } - } - return true; // All controls are empty + // Use shared utility to check if the form group is empty + return isFormGroupEmpty(firstGroupControl); } protected getEvent($event: any, arrayContext: DynamicFormArrayModel, index: number, type: string): DynamicFormControlEvent { diff --git a/src/app/shared/form/utils/form-group-empty.util.ts b/src/app/shared/form/utils/form-group-empty.util.ts new file mode 100644 index 00000000000..e959ca675bf --- /dev/null +++ b/src/app/shared/form/utils/form-group-empty.util.ts @@ -0,0 +1,66 @@ +import { UntypedFormGroup } from '@angular/forms'; +import { hasValue } from '../../empty.util'; + +/** + * Utility function to check if a form group control is visually empty. + * A form group is considered empty if all its form controls have no meaningful values. + * + * Handles multiple value types: + * - Strings: checked for non-empty trimmed value + * - Arrays: checked for length > 0 + * - Objects (FormFieldMetadataValueObject): checked for value property + * - Numbers/Booleans: always non-empty if present + * - Objects: checked for any non-null, non-empty properties + * + * @param formGroup The form group to check for emptiness + * @returns true if all controls in the form group are empty, false if any control has a value + */ +export function isFormGroupEmpty(formGroup: UntypedFormGroup): boolean { + if (!formGroup || !formGroup.controls) { + return true; + } + + const controlNames = Object.keys(formGroup.controls); + + for (const controlName of controlNames) { + const control = formGroup.get(controlName); + if (control) { + const value = control.value; + + // Check for non-empty values (handle strings, objects, arrays) + if (hasValue(value)) { + if (typeof value === 'string' && value.trim() !== '') { + return false; // Has string value + } else if (typeof value === 'object' && value !== null) { + // Check if object has meaningful properties + if (Array.isArray(value)) { + if (value.length > 0) { + return false; // Has array values + } + } else if (value.hasOwnProperty('value') && value.value && value.value !== '') { + return false; // Has FormFieldMetadataValueObject with value + } else { + // Check if object has any non-null properties + const objKeys = Object.keys(value); + const hasNonNullProperty = objKeys.some(key => { + const propValue = value[key]; + // Check if value is non-empty (excluding empty arrays) + const isNonNull = propValue !== null && + propValue !== undefined && + propValue !== '' && + !(Array.isArray(propValue) && propValue.length === 0); + return isNonNull; + }); + if (hasNonNullProperty) { + return false; + } + } + } else if (typeof value === 'number' || typeof value === 'boolean') { + return false; // Has numeric or boolean value + } + } + } + } + + return true; // All controls are empty +} From efe8b282f4e7bf3e901d32932c46eb2d3f11eeb3 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 11:30:07 +0100 Subject: [PATCH 08/16] Copilot Suggestions: Make section form SSR-safe (use window service) Replace direct window access in SubmissionSectionFormComponent with NativeWindowService and isPlatformBrowser checks to avoid SSR errors. Inject PLATFORM_ID and NativeWindowRef, only preserve/restore scroll position when running in the browser, and simplify some type casts in the combineLatest usage. Update the unit test to provide NativeWindowService and PLATFORM_ID. Also update array-group template to use shouldHideGroup(...) instead of isGroupEmpty(...). --- .../dynamic-form-array.component.html | 2 +- .../form/section-form.component.spec.ts | 5 ++- .../sections/form/section-form.component.ts | 43 +++++++++++++------ 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html index b374561cdc6..9309f062297 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html @@ -41,7 +41,7 @@
-
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 5cc04348009..daadfdbda82 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core'; import {ComponentFixture, inject, TestBed, waitForAsync} from '@angular/core/testing'; import { of as observableOf } from 'rxjs'; @@ -47,6 +47,7 @@ import { SubmissionSectionError } from '../../objects/submission-section-error.m import { mockItemWithMetadataFieldsAndValue } from '../../../item-page/simple/field-components/specific-field/item-page-field.component.spec'; +import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service'; function getMockSubmissionFormsConfigService(): SubmissionFormsConfigDataService { return jasmine.createSpyObj('FormOperationsService', { @@ -195,6 +196,8 @@ describe('SubmissionSectionFormComponent test suite', () => { { provide: 'sectionDataProvider', useValue: Object.assign({}, sectionObject) }, { provide: 'submissionIdProvider', useValue: submissionId }, { provide: SubmissionObjectDataService, useValue: submissionObjectDataService }, + { provide: NativeWindowService, useClass: NativeWindowRef }, + { provide: PLATFORM_ID, useValue: 'browser' }, ChangeDetectorRef, SubmissionSectionFormComponent ], diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 2bbe9aee8c9..1a8fd8f91de 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -1,4 +1,5 @@ -import { ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, Inject, ViewChild, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { DynamicFormControlEvent, DynamicFormControlModel } from '@ng-dynamic-forms/core'; import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; @@ -41,6 +42,7 @@ import { SubmissionSectionError } from '../../objects/submission-section-error.m import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; import { SPONSOR_METADATA_NAME } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-complex.model'; import { AUTHOR_METADATA_FIELD_NAME } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/clarin-name.model'; +import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service'; /** * This component represents a section that contains a Form. @@ -177,7 +179,9 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { protected requestService: RequestService, @Inject('collectionIdProvider') public injectedCollectionId: string, @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, - @Inject('submissionIdProvider') public injectedSubmissionId: string) { + @Inject('submissionIdProvider') public injectedSubmissionId: string, + @Inject(NativeWindowService) private _window: NativeWindowRef, + @Inject(PLATFORM_ID) private platformId: any) { super(injectedCollectionId, injectedSectionData, injectedSubmissionId); this.typeFields = new Map(); } @@ -194,22 +198,21 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { map((configData: RemoteData) => configData.payload), tap((config: SubmissionFormsModel) => this.formConfig = config), mergeMap(() => - observableCombineLatest([ - this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType), + observableCombineLatest<[WorkspaceitemSectionFormObject, SubmissionObject, boolean]>([ + this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType) as Observable, this.submissionObjectService.findById(this.submissionId, true, false, followLink('item')).pipe( getFirstSucceededRemoteData(), getRemoteDataPayload()), this.sectionService.isSectionReadOnly(this.submissionId, this.sectionData.id, this.submissionService.getSubmissionScope()) ])), take(1)) - // @ts-ignore - Type union complexity with WorkspaceitemSectionDataType (pre-existing) .subscribe(([sectionData, submissionObject, isSectionReadOnly]) => { if (isUndefined(this.formModel)) { // this.sectionData.errorsToShow = []; - this.submissionObject = submissionObject as SubmissionObject; - this.isSectionReadonly = isSectionReadOnly as boolean; + this.submissionObject = submissionObject; + this.isSectionReadonly = isSectionReadOnly; // Is the first loading so init form - this.initForm(sectionData as WorkspaceitemSectionFormObject); + this.initForm(sectionData); this.sectionData.data = sectionData; this.subscriptions(); this.isLoading = false; @@ -352,7 +355,11 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { if (isNotEmpty(sectionData) && !isEqual(sectionData, this.sectionData.data)) { this.sectionData.data = sectionData; if (this.hasMetadataEnrichment(sectionData)) { - const scrollPosition = window.pageYOffset || document.documentElement.scrollTop; + // Only preserve scroll position in browser environment (SSR safe) + let scrollPosition = 0; + if (isPlatformBrowser(this.platformId)) { + scrollPosition = this._window.nativeWindow.pageYOffset || this._window.nativeWindow.document.documentElement.scrollTop; + } this.isUpdating = true; this.formModel = null; this.cdr.detectChanges(); @@ -360,7 +367,10 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.checksForErrors(errors); this.isUpdating = false; this.cdr.detectChanges(); - window.scrollTo(0, scrollPosition); + // Restore scroll position only in browser environment + if (isPlatformBrowser(this.platformId)) { + this._window.nativeWindow.scrollTo(0, scrollPosition); + } } else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errorsToShow)) { this.checksForErrors(errors); } @@ -497,13 +507,18 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { // @ts-ignore if (metadataValueFromDB[index].value === newMetadataValue.value) { // update form - // Preserve scroll position to prevent unwanted scroll behavior - const scrollPosition = window.pageYOffset || document.documentElement.scrollTop; + // Preserve scroll position to prevent unwanted scroll behavior (SSR safe) + let scrollPosition = 0; + if (isPlatformBrowser(this.platformId)) { + scrollPosition = this._window.nativeWindow.pageYOffset || this._window.nativeWindow.document.documentElement.scrollTop; + } this.formModel = undefined; this.cdr.detectChanges(); this.ngOnInit(); - // Restore scroll position after form rebuild - window.scrollTo(0, scrollPosition); + // Restore scroll position after form rebuild (browser-only) + if (isPlatformBrowser(this.platformId)) { + this._window.nativeWindow.scrollTo(0, scrollPosition); + } clearInterval(interval); this.isUpdating = false; } From c680494b0f6502a338bea0255e6c3a744476a37c Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 11:39:20 +0100 Subject: [PATCH 09/16] Copilot suggestion: Remove unused isSponsor and metadataKey vars Cleanup: remove unused variables (metadataKey and isSponsor) from FormComponent.clearItemValues and SubmissionSectionFormComponent.onRemove. These were dead locals that didn't affect behavior; removal reduces clutter and potential linter warnings. --- src/app/shared/form/form.component.ts | 3 --- src/app/submission/sections/form/section-form.component.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index e01106354e3..3bc7d50d183 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -361,9 +361,6 @@ export class FormComponent implements OnDestroy, OnInit { * @param index The index of the group to clear */ clearItemValues($event: any, arrayContext: DynamicFormArrayModel, index: number): void { - const metadataKey = (arrayContext as any).metadataKey; - const isSponsor = metadataKey === 'local.sponsor'; - // Get the form control BEFORE emitting/resetting to log values const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as UntypedFormArray; const groupControl = formArrayControl.at(index); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 1a8fd8f91de..a430ddaefb5 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -573,7 +573,6 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { onRemove(event: DynamicFormControlEvent): void { const fieldId = this.formBuilderService.getId(event.model); const fieldIndex = this.formOperationsService.getArrayIndexFromEvent(event); - const isSponsor = fieldId === 'local.sponsor'; const hasStored = this.hasStoredValue(fieldId, fieldIndex); // Keep track that this field will be removed From 3ed13f6dedf5fd2fd92e0beec6324b7b2c75a506 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 11:39:31 +0100 Subject: [PATCH 10/16] Update section-form-operations.service.ts --- .../sections/form/section-form-operations.service.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 1a9401de0ef..3df070d261f 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -60,9 +60,6 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject, hasStoredValue: boolean): void { - const fieldId = this.formBuilder.getId(event.model); - const isSponsor = fieldId === 'local.sponsor'; - switch (event.type) { case 'remove': this.dispatchOperationsFromRemoveEvent(pathCombiner, event, previousValue, hasStoredValue); @@ -304,10 +301,6 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject, hasStoredValue: boolean): void { - - const fieldId = this.formBuilder.getId(event.model); - const isSponsor = fieldId === 'local.sponsor'; - const path = this.getFieldPathFromEvent(event); const value = this.getFieldValueFromChangeEvent(event); @@ -527,9 +520,6 @@ export class SectionFormOperationsService { previousValue: FormFieldPreviousValueObject, isRemoveEvent: boolean = false, hasStoredValue: boolean = false) { - const fieldId = model.metadataKey || this.formBuilder.getId(event.model); - const isSponsor = fieldId === 'local.sponsor'; - const arrayValue = this.formBuilder.getValueFromModel([model]); const segmentedPath = this.getFieldPathSegmentedFromChangeEvent(event); From 64454dada8ea139abce8b8ad2657f1b425eb75ac Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 13:22:05 +0100 Subject: [PATCH 11/16] Add tests for form array actions; clean error log Adds unit tests for FormComponent array group behaviors: revealFirstGroup, clearItemValues, and handleItemDelete (covering single/multi-item and hideGroupsWhenEmpty cases). Imports UntypedFormArray in the spec. Also simplifies error logging in SubmissionSectionFormComponent by removing a redundant cast/ts-ignore and logging e?.stack || e directly. --- src/app/shared/form/form.component.spec.ts | 78 ++++++++++++++++++- .../sections/form/section-form.component.ts | 4 +- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/app/shared/form/form.component.spec.ts b/src/app/shared/form/form.component.spec.ts index 2f3be3fded3..74a60c4fbb1 100644 --- a/src/app/shared/form/form.component.spec.ts +++ b/src/app/shared/form/form.component.spec.ts @@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/c import { ComponentFixture, inject, TestBed, waitForAsync, } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { BrowserModule } from '@angular/platform-browser'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule, UntypedFormArray } from '@angular/forms'; import { DynamicFormArrayModel, DynamicFormControlEvent, @@ -433,6 +433,82 @@ describe('FormComponent test suite', () => { expect(formComp.removeArrayItem.emit).toHaveBeenCalled(); })); + + it('revealFirstGroup should set hideGroupsWhenEmpty to false and call formService.changeForm', inject([FormBuilderService], (service: FormBuilderService) => { + spyOn((formComp as any).formService, 'changeForm'); + const arrayModel = formComp.formModel[0] as DynamicFormArrayModel; + (arrayModel as any).hideGroupsWhenEmpty = true; + + formComp.revealFirstGroup(new Event('click'), arrayModel); + + expect((arrayModel as any).hideGroupsWhenEmpty).toBe(false); + expect((formComp as any).formService.changeForm).toHaveBeenCalledWith(formComp.formId, formComp.formModel); + })); + + it('clearItemValues should reset group control and mark as dirty', inject([FormBuilderService], (service: FormBuilderService) => { + spyOn(formComp.removeArrayItem, 'emit'); + spyOn((formComp as any).formService, 'changeForm'); + + const arrayModel = formComp.formModel[0] as DynamicFormArrayModel; + (arrayModel as any).hideGroupsWhenEmpty = false; + + formComp.clearItemValues(new Event('click'), arrayModel, 0); + + expect((arrayModel as any).hideGroupsWhenEmpty).toBe(true); + + expect((formComp as any).formService.changeForm).toHaveBeenCalledWith(formComp.formId, formComp.formModel); + + expect(formComp.removeArrayItem.emit).toHaveBeenCalled(); + + const emittedEvent = (formComp.removeArrayItem.emit as jasmine.Spy).calls.mostRecent().args[0]; + expect((emittedEvent as any).isClearLastItem).toBe(true); + })); + + it('handleItemDelete should call clearItemValues for single-item hideWhenEmpty array', inject([FormBuilderService], (service: FormBuilderService) => { + spyOn(formComp, 'clearItemValues'); + spyOn(formComp, 'removeItem'); + + const arrayModel = formComp.formModel[0] as DynamicFormArrayModel; + (arrayModel as any).hideGroupsWhenEmpty = true; + while (arrayModel.groups.length > 1) { + arrayModel.groups.pop(); + } + + formComp.handleItemDelete(new Event('click'), arrayModel, 0); + + expect(formComp.clearItemValues).toHaveBeenCalledWith(jasmine.any(Event), arrayModel, 0); + expect(formComp.removeItem).not.toHaveBeenCalled(); + })); + + it('handleItemDelete should call removeItem for multi-item array', inject([FormBuilderService], (service: FormBuilderService) => { + spyOn(formComp, 'clearItemValues'); + spyOn(formComp, 'removeItem'); + + const arrayModel = formComp.formModel[0] as DynamicFormArrayModel; + (arrayModel as any).hideGroupsWhenEmpty = true; + // Add multiple groups + if (arrayModel.groups.length === 1) { + arrayModel.addGroup(); + } + + formComp.handleItemDelete(new Event('click'), arrayModel, 0); + + expect(formComp.removeItem).toHaveBeenCalledWith(jasmine.any(Event), arrayModel, 0); + expect(formComp.clearItemValues).not.toHaveBeenCalled(); + })); + + it('handleItemDelete should call removeItem when hideGroupsWhenEmpty is false', inject([FormBuilderService], (service: FormBuilderService) => { + spyOn(formComp, 'clearItemValues'); + spyOn(formComp, 'removeItem'); + + const arrayModel = formComp.formModel[0] as DynamicFormArrayModel; + (arrayModel as any).hideGroupsWhenEmpty = false; + + formComp.handleItemDelete(new Event('click'), arrayModel, 0); + + expect(formComp.removeItem).toHaveBeenCalledWith(jasmine.any(Event), arrayModel, 0); + expect(formComp.clearItemValues).not.toHaveBeenCalled(); + })); }); }); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index a430ddaefb5..07e762bdf86 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -335,9 +335,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { message: msg, path: '/sections/' + this.sectionData.id }; - // @ts-ignore - Unknown type doesn't have stack property (pre-existing) - const error = e as Error; - console.error(error?.stack || e); + console.error(e?.stack || e); this.sectionService.setSectionError(this.submissionId, this.sectionData.id, sectionError); } } From b7e38e6e97ef73a02a977ecbe8e20ce3e5f1ccba Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 14:00:48 +0100 Subject: [PATCH 12/16] Add empty-state cache; replace sponsor string Optimize FormComponent by adding an emptyStateCache (Map) to memoize isFirstGroupEmpty results and avoid repeated expensive isFormGroupEmpty calls during change detection. The cache is cleared on formGroup.valueChanges via a new subscription and early false values are stored for invalid states. Also replace hardcoded 'local.sponsor' checks in FieldParser with the SPONSOR_METADATA_NAME constant import for better maintainability. --- .../form/builder/parsers/field-parser.ts | 5 +-- src/app/shared/form/form.component.ts | 33 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 38d09460cca..0c9e9a6edf9 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 { SPONSOR_METADATA_NAME } from '../ds-dynamic-form-ui/models/ds-dynamic-complex.model'; export const SUBMISSION_ID: InjectionToken = new InjectionToken('submissionId'); export const CONFIG_DATA: InjectionToken = new InjectionToken('configData'); @@ -87,9 +88,9 @@ export abstract class FieldParser { hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata), isDraggable, hideGroupsWhenEmpty: this.configData.input.type === ParserType.Complex && - metadataKey === 'local.sponsor', + metadataKey === SPONSOR_METADATA_NAME, allowDeleteOnSingleItem: this.configData.input.type === ParserType.Complex && - metadataKey === 'local.sponsor', + metadataKey === SPONSOR_METADATA_NAME, typeBindRelations: isNotEmpty(this.configData.typeBind) ? this.getTypeBindRelations(this.configData.typeBind, this.parserOptions.typeField) : null, groupFactory: () => { diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 3bc7d50d183..7fdd040bc78 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -34,6 +34,13 @@ export class FormComponent implements OnDestroy, OnInit { private formErrors: FormError[] = []; private formValid: boolean; + /** + * Cache for isFirstGroupEmpty results to optimize change detection. + * Cleared automatically on form value changes to prevent stale data. + * Key: arrayContext.id, Value: boolean (isEmpty result) + */ + private emptyStateCache: Map = new Map(); + /** * A boolean that indicate if to display form's submit button */ @@ -168,6 +175,12 @@ export class FormComponent implements OnDestroy, OnInit { this.formValid = this.getFormGroupValidStatus(); })); + // Clear empty state cache on form value changes to ensure fresh calculations + // This prevents stale cache while avoiding repeated expensive computations during change detection + this.subs.push(this.formGroup.valueChanges.subscribe(() => { + this.emptyStateCache.clear(); + })); + this.subs.push( this.formService.getForm(this.formId).pipe( filter((formState: FormEntry) => !!formState && (isNotEmpty(formState.errors) || isNotEmpty(this.formErrors))), @@ -471,29 +484,45 @@ export class FormComponent implements OnDestroy, OnInit { * This is used to determine visual-empty state independent of structural state (FormArray always has 1 group minimum). * Delegates to shared utility function for consistent empty detection. * + * Uses memoization to optimize performance during change detection. Cache is cleared on form value changes. + * * @param arrayContext The array model context * @returns true if first group exists and all its controls are empty/null */ isFirstGroupEmpty(arrayContext: DynamicFormArrayModel): boolean { + // Check cache first to avoid repeated expensive computations during change detection + const cacheKey = arrayContext.id; + if (this.emptyStateCache.has(cacheKey)) { + return this.emptyStateCache.get(cacheKey); + } + // Must have at least one group if (!arrayContext.groups || arrayContext.groups.length === 0) { + this.emptyStateCache.set(cacheKey, false); return false; } // Get the FormArray control const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as UntypedFormArray; if (!formArrayControl || formArrayControl.length === 0) { + this.emptyStateCache.set(cacheKey, false); return false; } // Get first group's FormGroup const firstGroupControl = formArrayControl.at(0) as UntypedFormGroup; if (!firstGroupControl) { + this.emptyStateCache.set(cacheKey, false); return false; } - // Use shared utility to check if the form group is empty - return isFormGroupEmpty(firstGroupControl); + // Use shared utility to check if the form group is empty (expensive operation) + const isEmpty = isFormGroupEmpty(firstGroupControl); + + // Cache the result for subsequent calls during this change detection cycle + this.emptyStateCache.set(cacheKey, isEmpty); + + return isEmpty; } protected getEvent($event: any, arrayContext: DynamicFormArrayModel, index: number, type: string): DynamicFormControlEvent { From 9838dbbfffd8a6a13637810ff89b84683557cb59 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 14:18:46 +0100 Subject: [PATCH 13/16] Remove @ts-ignore and add inline cast in map Replace the suppressed TypeScript check and typed map parameter with an untyped parameter and an inline cast: map((configData) => configData.payload as SubmissionFormsModel). This removes the // @ts-ignore workaround for the ConfigObject vs SubmissionFormsModel mismatch and clarifies the payload type without changing runtime behavior. --- src/app/submission/sections/form/section-form.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 07e762bdf86..5ba9aa76b7f 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -194,8 +194,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { 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( - // @ts-ignore - Type mismatch between ConfigObject and SubmissionFormsModel (pre-existing) - map((configData: RemoteData) => configData.payload), + map((configData) => configData.payload as SubmissionFormsModel), tap((config: SubmissionFormsModel) => this.formConfig = config), mergeMap(() => observableCombineLatest<[WorkspaceitemSectionFormObject, SubmissionObject, boolean]>([ From 8ac740fd7fc6672cc8235134f8db3b0429752874 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Tue, 10 Feb 2026 15:32:50 +0100 Subject: [PATCH 14/16] Remove unused imports from form tests and section Cleanup: remove unused imports to silence TS/linter warnings. Deleted an unused `UntypedFormArray` import from src/app/shared/form/form.component.spec.ts and an unused `RemoteData` import from src/app/submission/sections/form/section-form.component.ts. No functional changes intended. --- src/app/shared/form/form.component.spec.ts | 2 +- src/app/submission/sections/form/section-form.component.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/shared/form/form.component.spec.ts b/src/app/shared/form/form.component.spec.ts index 8244da001a4..d80ddc2bbee 100644 --- a/src/app/shared/form/form.component.spec.ts +++ b/src/app/shared/form/form.component.spec.ts @@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/c import { ComponentFixture, inject, TestBed, waitForAsync, } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { BrowserModule } from '@angular/platform-browser'; -import { FormsModule, ReactiveFormsModule, UntypedFormArray } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { DynamicFormArrayModel, DynamicFormControlEvent, diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 3b87bf12e0f..e7991b17ec7 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -33,7 +33,6 @@ import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { RequestService } from '../../../core/data/request.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { environment } from '../../../../environments/environment'; -import { RemoteData } from '../../../core/data/remote-data'; import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; From 5b99ce6e8ca8d94d303019a1183316716c30e45d Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Fri, 6 Mar 2026 10:29:50 +0100 Subject: [PATCH 15/16] Use allowDeleteOnSingleItem and trigger save Fix form array delete behavior and ensure form changes trigger a save. handleItemDelete now checks allowDeleteOnSingleItem for single-item arrays (instead of hideGroupsWhenEmpty) so arrays configured to allow delete on a single item will clear values rather than remove the group. SubmissionSectionFormComponent now calls submissionService.dispatchSave(this.submissionId) after handling value changes. Corresponding unit tests were updated/added to assert clearItemValues is used for single-item arrays with allowDeleteOnSingleItem and to expect dispatchSave, plus a spy reset adjustment in the section form tests. --- src/app/shared/form/form.component.spec.ts | 18 ++++++++++++++++++ src/app/shared/form/form.component.ts | 4 ++-- .../form/section-form.component.spec.ts | 3 ++- .../sections/form/section-form.component.ts | 2 ++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/app/shared/form/form.component.spec.ts b/src/app/shared/form/form.component.spec.ts index d80ddc2bbee..da138fb705d 100644 --- a/src/app/shared/form/form.component.spec.ts +++ b/src/app/shared/form/form.component.spec.ts @@ -472,6 +472,24 @@ describe('FormComponent test suite', () => { const arrayModel = formComp.formModel[0] as DynamicFormArrayModel; (arrayModel as any).hideGroupsWhenEmpty = true; + (arrayModel as any).allowDeleteOnSingleItem = true; + while (arrayModel.groups.length > 1) { + arrayModel.groups.pop(); + } + + formComp.handleItemDelete(new Event('click'), arrayModel, 0); + + expect(formComp.clearItemValues).toHaveBeenCalledWith(jasmine.any(Event), arrayModel, 0); + expect(formComp.removeItem).not.toHaveBeenCalled(); + })); + + it('handleItemDelete should call clearItemValues for single-item allowDeleteOnSingleItem array when hideGroupsWhenEmpty is false', inject([FormBuilderService], (service: FormBuilderService) => { + spyOn(formComp, 'clearItemValues'); + spyOn(formComp, 'removeItem'); + + const arrayModel = formComp.formModel[0] as DynamicFormArrayModel; + (arrayModel as any).allowDeleteOnSingleItem = true; + (arrayModel as any).hideGroupsWhenEmpty = false; while (arrayModel.groups.length > 1) { arrayModel.groups.pop(); } diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 4992a19d816..b69a5a182eb 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -414,9 +414,9 @@ export class FormComponent implements OnDestroy, OnInit { * @param index The index of the group to delete */ handleItemDelete($event: any, arrayContext: DynamicFormArrayModel, index: number): void { - const hideWhenEmpty = (arrayContext as any).hideGroupsWhenEmpty; + const allowDeleteSingle = (arrayContext as any).allowDeleteOnSingleItem; const isSingleGroup = arrayContext.groups.length === 1; - const shouldClear = hideWhenEmpty && isSingleGroup; + const shouldClear = allowDeleteSingle && isSingleGroup; // For single-item arrays with hideGroupsWhenEmpty: clear instead of remove if (shouldClear) { 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 97e4e6989cb..def12e9a809 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -636,6 +636,7 @@ describe('SubmissionSectionFormComponent test suite', () => { comp.onRemove(dynamicFormControlEvent); expect(formOperationsService.dispatchOperationsFromEvent).toHaveBeenCalled(); + expect(submissionServiceStub.dispatchSave).toHaveBeenCalledWith(submissionId); }); @@ -685,7 +686,7 @@ describe('SubmissionSectionFormComponent test suite', () => { spyOn(comp, 'initForm'); spyOn(comp, 'subscriptions'); spyOn(comp, 'reinitializeForm'); - spyOn(submissionServiceStub, 'dispatchSaveSection'); + submissionServiceStub.dispatchSaveSection.calls.reset(); const wi = new WorkspaceItem(); wi.item = createSuccessfulRemoteDataObject$(mockItemWithMetadataFieldsAndValue(['local.sponsor'], EU_SPONSOR)); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index e7991b17ec7..f1b152582db 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -586,6 +586,8 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { event, this.previousValue, hasStored); + + this.submissionService.dispatchSave(this.submissionId); } /** From c6766b49fcac0c42c04c80c2e62e8bdae4c9e0b3 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Fri, 6 Mar 2026 12:19:40 +0100 Subject: [PATCH 16/16] Use fakeAsync/tick in CC license tests Replace ad-hoc setTimeout and done usage with Angular testing fakeAsync and tick in SubmissionSectionCcLicensesComponent spec. Wrap beforeEach blocks and the async test in fakeAsync, add tick(300) calls to simulate the debounce delay, reset the getCcLicenseLink spy before assertions, and remove the setTimeout-based asynchronous test flow to stabilize and simplify the tests. --- ...sion-section-cc-licenses.component.spec.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts index dc9bf3c028e..bac098d5258 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts @@ -210,10 +210,12 @@ describe('SubmissionSectionCcLicensesComponent', () => { const ccLicence = submissionCcLicenses[1]; - beforeEach(() => { + beforeEach(fakeAsync(() => { component.selectCcLicense(ccLicence); fixture.detectChanges(); - }); + tick(300); + fixture.detectChanges(); + })); it('should display the selected cc license', () => { expect(component.selectedCcLicense.name).toContain('test license name 2'); @@ -236,11 +238,14 @@ describe('SubmissionSectionCcLicensesComponent', () => { describe('when all options have a value selected', () => { - beforeEach(() => { + beforeEach(fakeAsync(() => { + submissionCcLicenseUrlDataService.getCcLicenseLink.calls.reset(); component.selectOption(ccLicence, ccLicence.fields[0], ccLicence.fields[0].enums[1]); component.selectOption(ccLicence, ccLicence.fields[1], ccLicence.fields[1].enums[0]); fixture.detectChanges(); - }); + tick(300); + fixture.detectChanges(); + })); it('should call the submission cc licenses data service getCcLicenseLink method', () => { expect(submissionCcLicenseUrlDataService.getCcLicenseLink).toHaveBeenCalledWith( @@ -252,15 +257,10 @@ describe('SubmissionSectionCcLicensesComponent', () => { ); }); - it('should display a cc license link', (done) => { - // Wait for the debounced observable to emit - setTimeout(() => { - fixture.detectChanges(); - const linkElement = de.query(By.css('.license-link')); - expect(linkElement).toBeTruthy(); - done(); - }, 350); // Wait longer than the 300ms debounce - }); + it('should display a cc license link', fakeAsync(() => { + const linkElement = de.query(By.css('.license-link')); + expect(linkElement).toBeTruthy(); + })); it('should not be accepted', () => { expect(component.accepted).toBeFalse();