Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6ca0a70
Remove section-level save on form field change
amadulhaxxani Jan 29, 2026
67367fc
Remove redundant save section dispatch test
amadulhaxxani Jan 29, 2026
f066005
fixing the scroll
amadulhaxxani Feb 4, 2026
2418bb4
Support hide/show for single-item array groups
amadulhaxxani Feb 9, 2026
8d722a0
Clean up imports and formatting in form code
amadulhaxxani Feb 9, 2026
656dd87
copilot suggestion for accessibility
amadulhaxxani Feb 10, 2026
2e8108b
Extract form group empty check to util
amadulhaxxani Feb 10, 2026
efe8b28
Copilot Suggestions: Make section form SSR-safe (use window service)
amadulhaxxani Feb 10, 2026
c680494
Copilot suggestion: Remove unused isSponsor and metadataKey vars
amadulhaxxani Feb 10, 2026
3ed13f6
Update section-form-operations.service.ts
amadulhaxxani Feb 10, 2026
64454da
Add tests for form array actions; clean error log
amadulhaxxani Feb 10, 2026
b7e38e6
Add empty-state cache; replace sponsor string
amadulhaxxani Feb 10, 2026
9838dbb
Remove @ts-ignore and add inline cast in map
amadulhaxxani Feb 10, 2026
98db3f0
Merge remote-tracking branch 'origin/clarin-v7' into 92-submission-fo…
amadulhaxxani Feb 10, 2026
8ac740f
Remove unused imports from form tests and section
amadulhaxxani Feb 10, 2026
7dea28b
Merge remote-tracking branch 'origin/clarin-v7' into 92-submission-fo…
kosarko Mar 4, 2026
5b99ce6
Use allowDeleteOnSingleItem and trigger save
amadulhaxxani Mar 6, 2026
c6766b4
Use fakeAsync/tick in CC license tests
amadulhaxxani Mar 6, 2026
b37f141
Merge branch 'clarin-v7' into 92-submission-form-changing-funding-to-…
amadulhaxxani May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
role="group"
[formGroupName]="groupModel.index"
[ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]"
[class.ds-form-array-group-hidden]="shouldHideGroup(idx)"
cdkDrag
[cdkDragDisabled]="dragDisabled"
[cdkDragPreviewClass]="'ds-submission-reorder-dragging'"
Expand Down Expand Up @@ -49,6 +50,10 @@
<ng-container *ngTemplateOutlet="endTemplate?.templateRef; context: groupModel"></ng-container>
</div>
</div>
<div *ngIf="model.hideGroupsWhenEmpty && model.groups.length === 1 && shouldHideGroup(0)"
class="clearfix w-100">
<ng-container *ngTemplateOutlet="endTemplate?.templateRef; context: model.groups[0]"></ng-container>
</div>
</div>

</ng-container>
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@
background-color: var(--bs-gray-400);
}
}

.ds-form-array-group-hidden {
display: none !important;
}
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,78 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
}));
}
}

/**
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig
hasSelectableMetadata: boolean;
isDraggable: boolean;
showButtons: boolean;
hideGroupsWhenEmpty?: boolean;
allowDeleteOnSingleItem?: boolean;
typeBindRelations?: DynamicFormControlRelation[];
isInlineGroupArray?: boolean;
}
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -110,12 +109,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;
}
Expand Down
23 changes: 19 additions & 4 deletions src/app/shared/form/builder/parsers/field-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ParserType } from './parser-type';
import { isNgbDateStruct } from '../../../date.util';
import { SubmissionScopeType } from '../../../../core/submission/submission-scope-type';
import { TranslateService } from '@ngx-translate/core';
import { SPONSOR_METADATA_NAME } from '../ds-dynamic-form-ui/models/ds-dynamic-complex.model';

export const SUBMISSION_ID: InjectionToken<string> = new InjectionToken<string>('submissionId');
export const CONFIG_DATA: InjectionToken<FormFieldModel> = new InjectionToken<FormFieldModel>('configData');
Expand Down Expand Up @@ -88,6 +89,10 @@ export abstract class FieldParser {
metadataFields: this.getAllFieldIds(),
hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata),
isDraggable,
hideGroupsWhenEmpty: this.configData.input.type === ParserType.Complex &&
metadataKey === SPONSOR_METADATA_NAME,
allowDeleteOnSingleItem: this.configData.input.type === ParserType.Complex &&
metadataKey === SPONSOR_METADATA_NAME,
typeBindRelations: isNotEmpty(this.configData.typeBind) ? this.getTypeBindRelations(this.configData.typeBind,
this.parserOptions.typeField) : null,
groupFactory: () => {
Expand Down Expand Up @@ -246,18 +251,28 @@ 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;

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;
const result = counter === 0 ? 1 : counter;
return result;
} else {
return 1;
const result = 1;
return result;
}
}

Expand Down
18 changes: 15 additions & 3 deletions src/app/shared/form/form.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@
(ngbEvent)="onCustomEvent($event)">
<ng-template modelType="ARRAY" let-group let-index="index" let-context="context">
<!--Array with repeatable items-->
<div *ngIf="(!context.notRepeatable) && !isVirtual(context, index) && group.context.groups.length !== 1 && !isItemReadOnly(context, index)"
<div *ngIf="shouldShowDeleteButton(context, index)"
class="col-xs-2 d-flex flex-column justify-content-sm-start align-items-end">
<button type="button" class="btn btn-secondary" role="button"
title="{{'form.remove' | translate}}"
attr.aria-label="{{'form.remove' | translate}}"
(click)="removeItem($event, context, index)">
(click)="handleItemDelete($event, context, index)">
<span><i class="fas fa-trash" aria-hidden="true"></i></span>
</button>
</div>
<div *ngIf="(!context.notRepeatable) && index === (group.context.groups.length - 1) && !isItemReadOnly(context, index)" class="clearfix pl-4 w-100">
<div *ngIf="(!context.notRepeatable) && index === (group.context.groups.length - 1) && !isItemReadOnly(context, index) && !shouldShowEmptyStateAddButton(context, index)" class="clearfix pl-4 w-100">
<div class="btn-group" role="group">
<button type="button" role="button" class="ds-form-add-more btn btn-link"
title="{{'form.add' | translate}}"
Expand All @@ -33,6 +33,18 @@
</div>
</div>

<div *ngIf="shouldShowEmptyStateAddButton(context, index)"
class="clearfix w-100">
<div class="btn-group" role="group">
<button type="button" role="button" class="ds-form-add-more btn btn-link"
title="{{'form.add-single' | translate}}"
attr.aria-label="{{'form.add-single' | translate}}"
(click)="revealFirstGroup($event, group.context)">
<span><i class="fas fa-plus" aria-hidden="true"></i> {{'form.add-single' | translate}}</span>
</button>
Comment thread
amadulhaxxani marked this conversation as resolved.
</div>
</div>

<!--Array with non repeatable items - Only discard button-->
<div *ngIf="context.notRepeatable && context.showButtons && group.context.groups.length > 1"
class="col-xs-2 d-flex flex-column justify-content-sm-start align-items-end">
Expand Down
94 changes: 94 additions & 0 deletions src/app/shared/form/form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,100 @@ 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;
(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();
}

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();
}));
});
});

Expand Down
Loading
Loading