Browse Source

nested form support added

pull/23917/head
erdemcaygor 2 weeks ago
parent
commit
90b2be548e
  1. 17
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html
  2. 137
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/form-config.service.ts
  3. 343
      npm/ng-packs/packages/components/dynamic-form/NESTED-FORMS.md
  4. 93
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.html
  5. 75
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.scss
  6. 88
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.ts
  7. 1
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/index.ts
  8. 4
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html
  9. 36
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.html
  10. 26
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.scss
  11. 46
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.ts
  12. 1
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/index.ts
  13. 36
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.html
  14. 8
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts
  15. 6
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.models.ts
  16. 51
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.service.ts
  17. 3
      npm/ng-packs/packages/components/dynamic-form/src/public-api.ts

17
npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html

@ -17,7 +17,7 @@
<i class="fas fa-wpforms me-2"></i>
User Registration Form
</h4>
<small>All 16 field types with accessibility support</small>
<small>All 16 field types + Nested Forms (Group & Array) with full accessibility support</small>
</div>
<div class="card-body p-4">
@if (formFields.length) {
@ -84,13 +84,26 @@
<li><i class="fas fa-check text-success"></i> File (Single/Multiple)</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-primary">Nested Forms</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success"></i> Group (Nested Fields)</li>
<li><i class="fas fa-check text-success"></i> Array (Dynamic List)</li>
</ul>
</div>
</div>
<hr>
<div class="alert alert-info mb-0">
<i class="fas fa-lightbulb me-2"></i>
<strong>Features:</strong> Full validation support, conditional logic,
<strong>Features:</strong> Full validation support, conditional logic, nested forms (groups & arrays),
grid-based layout, ARIA accessibility, keyboard navigation, and screen reader support.
</div>
<hr>
<div class="alert alert-success mb-0">
<i class="fas fa-star me-2"></i>
<strong>New:</strong> Try the nested forms below! Add/remove phone numbers dynamically,
or fill in your work experience. The Address section shows a grouped form.
</div>
</div>
</div>
</div>

137
npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/form-config.service.ts

@ -237,6 +237,143 @@ export class FormConfigService {
order: 19,
validators: [{ type: 'requiredTrue', message: 'You must agree to the terms' }],
},
// Section 10: NESTED FORM - Phone Numbers (Array)
{
key: 'phoneNumbers',
type: 'array',
label: 'Phone Numbers',
gridSize: 12,
order: 20,
minItems: 1,
maxItems: 5,
children: [
{
key: 'type',
type: 'select',
label: 'Type',
gridSize: 4,
required: true,
options: {
defaultValues: [
{ key: 'mobile', value: 'Mobile' },
{ key: 'home', value: 'Home' },
{ key: 'work', value: 'Work' },
{ key: 'other', value: 'Other' }
]
},
validators: [{ type: 'required', message: 'Phone type is required' }],
},
{
key: 'number',
type: 'tel',
label: 'Number',
gridSize: 8,
required: true,
placeholder: '555-123-4567',
validators: [{ type: 'required', message: 'Phone number is required' }],
},
]
},
// Section 11: NESTED FORM - Work Experience (Array with nested group)
{
key: 'workExperience',
type: 'array',
label: 'Work Experience',
gridSize: 12,
order: 21,
minItems: 0,
maxItems: 10,
children: [
{
key: 'company',
type: 'text',
label: 'Company Name',
gridSize: 6,
required: true,
validators: [{ type: 'required', message: 'Company name is required' }],
},
{
key: 'position',
type: 'text',
label: 'Position',
gridSize: 6,
required: true,
validators: [{ type: 'required', message: 'Position is required' }],
},
{
key: 'startDate',
type: 'date',
label: 'Start Date',
gridSize: 6,
required: true,
validators: [{ type: 'required', message: 'Start date is required' }],
},
{
key: 'endDate',
type: 'date',
label: 'End Date',
gridSize: 6,
},
{
key: 'currentJob',
type: 'checkbox',
label: 'Currently working here',
gridSize: 12,
},
{
key: 'description',
type: 'textarea',
label: 'Job Description',
placeholder: 'Describe your responsibilities...',
gridSize: 12,
maxLength: 500,
},
]
},
// Section 12: NESTED FORM - Address Group (Group type)
{
key: 'address',
type: 'group',
label: 'Address Information',
gridSize: 12,
order: 22,
children: [
{
key: 'street',
type: 'text',
label: 'Street Address',
gridSize: 8,
placeholder: '123 Main St',
},
{
key: 'apartment',
type: 'text',
label: 'Apt/Suite',
gridSize: 4,
placeholder: 'Apt 4B',
},
{
key: 'city',
type: 'text',
label: 'City',
gridSize: 6,
required: true,
validators: [{ type: 'required', message: 'City is required' }],
},
{
key: 'zipCode',
type: 'text',
label: 'ZIP Code',
gridSize: 6,
required: true,
pattern: '[0-9]{5}',
validators: [{ type: 'required', message: 'ZIP code is required' }],
},
]
},
];
return of(formConfig);

343
npm/ng-packs/packages/components/dynamic-form/NESTED-FORMS.md

@ -0,0 +1,343 @@
# Nested Forms Guide
## Overview
Dynamic Form now supports **nested forms** with two new field types:
- **`group`** - Group related fields together
- **`array`** - Dynamic lists with add/remove functionality
## Quick Start
### 1. Group Type (Nested Fields)
Group related fields together with visual hierarchy:
```typescript
{
key: 'address',
type: 'group',
label: 'Address Information',
gridSize: 12,
children: [
{
key: 'street',
type: 'text',
label: 'Street',
gridSize: 8
},
{
key: 'city',
type: 'text',
label: 'City',
gridSize: 4
},
{
key: 'zipCode',
type: 'text',
label: 'ZIP Code',
gridSize: 6
}
]
}
```
**Output:**
```json
{
"address": {
"street": "123 Main St",
"city": "New York",
"zipCode": "10001"
}
}
```
### 2. Array Type (Dynamic Lists)
Create dynamic lists with add/remove buttons:
```typescript
{
key: 'phoneNumbers',
type: 'array',
label: 'Phone Numbers',
minItems: 1,
maxItems: 5,
gridSize: 12,
children: [
{
key: 'type',
type: 'select',
label: 'Type',
gridSize: 4,
options: {
defaultValues: [
{ key: 'mobile', value: 'Mobile' },
{ key: 'home', value: 'Home' },
{ key: 'work', value: 'Work' }
]
}
},
{
key: 'number',
type: 'tel',
label: 'Number',
gridSize: 8
}
]
}
```
**Output:**
```json
{
"phoneNumbers": [
{ "type": "mobile", "number": "555-1234" },
{ "type": "work", "number": "555-5678" }
]
}
```
## Features
### Array Features
- ✅ **Add Button** - Adds new item (respects maxItems)
- ✅ **Remove Button** - Removes item (respects minItems)
- ✅ **Item Counter** - Shows current count and limits
- ✅ **Item Labels** - "Phone Number #1", "Phone Number #2"
- ✅ **Min/Max Validation** - Buttons automatically disabled
- ✅ **Empty State** - Shows info message when no items
### Group Features
- ✅ **Visual Hierarchy** - Border and background styling
- ✅ **Legend Label** - Fieldset with legend for accessibility
- ✅ **Grid Support** - All children support gridSize
- ✅ **Nested Groups** - Groups inside groups supported
### Recursive Support
- ✅ **Array in Array** - Phone numbers can have sub-arrays
- ✅ **Group in Array** - Work experience can have grouped fields
- ✅ **Array in Group** - Address can have multiple phone numbers
- ✅ **Unlimited Nesting** - No depth limit
## Advanced Examples
### Complex Nested Structure
```typescript
{
key: 'workExperience',
type: 'array',
label: 'Work Experience',
minItems: 0,
maxItems: 10,
children: [
{
key: 'company',
type: 'text',
label: 'Company Name',
gridSize: 6,
required: true
},
{
key: 'position',
type: 'text',
label: 'Position',
gridSize: 6,
required: true
},
{
key: 'dates',
type: 'group', // Nested group inside array
label: 'Employment Dates',
gridSize: 12,
children: [
{
key: 'startDate',
type: 'date',
label: 'Start Date',
gridSize: 6
},
{
key: 'endDate',
type: 'date',
label: 'End Date',
gridSize: 6
}
]
},
{
key: 'description',
type: 'textarea',
label: 'Description',
gridSize: 12
}
]
}
```
## API Reference
### FormFieldConfig (Extended)
```typescript
interface FormFieldConfig {
// ... existing properties
// NEW: Nested form properties
children?: FormFieldConfig[]; // Child fields for group/array types
minItems?: number; // Minimum items for array (default: 0)
maxItems?: number; // Maximum items for array (default: unlimited)
}
```
### New Components
#### DynamicFormGroupComponent
```typescript
@Input() groupConfig: FormFieldConfig;
@Input() formGroup: FormGroup;
@Input() visible: boolean = true;
```
#### DynamicFormArrayComponent
```typescript
@Input() arrayConfig: FormFieldConfig;
@Input() formGroup: FormGroup;
@Input() visible: boolean = true;
addItem(): void; // Add new item to array
removeItem(index): void; // Remove item from array
```
## Styling
### Group Styling
```scss
.form-group-container {
border-left: 3px solid var(--bs-primary);
padding: 1rem;
background-color: var(--bs-light);
}
```
### Array Styling
```scss
.array-item {
border: 1px solid var(--bs-border-color);
padding: 1rem;
margin-bottom: 1rem;
background: white;
&:hover {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
}
```
## Accessibility
All nested forms include:
- ✅ **ARIA roles** (`role="group"`, `role="list"`, `role="listitem"`)
- ✅ **ARIA labels** (`aria-label`, `aria-labelledby`)
- ✅ **Live regions** (`aria-live="polite"` for item count)
- ✅ **Semantic HTML** (`<fieldset>`, `<legend>`)
- ✅ **Keyboard navigation** (Tab, Enter, Space)
- ✅ **Screen reader announcements**
## Migration Guide
### From Simple to Nested
**Before:**
```typescript
{
key: 'street',
type: 'text',
label: 'Street'
},
{
key: 'city',
type: 'text',
label: 'City'
}
```
**After:**
```typescript
{
key: 'address',
type: 'group',
label: 'Address',
children: [
{ key: 'street', type: 'text', label: 'Street' },
{ key: 'city', type: 'text', label: 'City' }
]
}
```
### Data Structure Change
**Before:**
```json
{
"street": "123 Main St",
"city": "New York"
}
```
**After:**
```json
{
"address": {
"street": "123 Main St",
"city": "New York"
}
}
```
## Best Practices
1. **Use Groups** for logical field grouping (address, contact info)
2. **Use Arrays** for dynamic lists (phone numbers, work history)
3. **Set minItems/maxItems** to prevent empty or excessive arrays
4. **Use gridSize** for responsive layouts within nested forms
5. **Keep nesting shallow** (max 2-3 levels for UX)
6. **Add validation** to required nested fields
7. **Use meaningful labels** for array items
## Examples
See `apps/dev-app/src/app/dynamic-form-page` for complete examples:
- Phone Numbers (simple array)
- Work Experience (complex array)
- Address (group)
## Troubleshooting
### Array items not showing
- Check `minItems` - may need to be > 0
- Verify `children` array is not empty
### Can't add items
- Check `maxItems` limit
- Verify button is not disabled
### Form data not nested
- Confirm `type: 'group'` or `type: 'array'`
- Check FormGroup structure in component
## Performance
- ✅ **OnPush** change detection
- ✅ **TrackBy** functions for arrays
- ✅ **Lazy rendering** for conditional fields
- ✅ **Minimal re-renders** on add/remove
---
**Version:** 1.0.0
**Added:** 2026-01-20
**Status:** Production Ready ✅

93
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.html

@ -0,0 +1,93 @@
@if (visible()) {
<div class="form-array-container" role="region" [attr.aria-label]="arrayConfig().label | abpLocalization">
<!-- Header with Add Button -->
<div class="array-header d-flex justify-content-between align-items-center mb-3">
<label class="form-array-label">
{{ arrayConfig().label | abpLocalization }}
@if (arrayConfig().required) {
<span class="text-danger" aria-label="required">*</span>
}
</label>
<button
type="button"
class="btn btn-sm btn-primary"
(click)="addItem()"
[disabled]="!canAddItem"
[attr.aria-label]="'Add ' + (arrayConfig().label | abpLocalization)">
<i class="fa fa-plus me-1"></i>
{{ '::Add' | abpLocalization }}
</button>
</div>
<!-- Array Items -->
<div class="array-items" role="list">
@for (item of formArray.controls; track trackByIndex($index)) {
<div class="array-item border rounded p-3 mb-3" role="listitem" [formGroup]="getItemFormGroup($index)">
<!-- Item Header -->
<div class="item-header d-flex justify-content-between align-items-center mb-3">
<strong class="item-title">
{{ arrayConfig().label | abpLocalization }} #{{ $index + 1 }}
</strong>
<button
type="button"
class="btn btn-sm btn-danger"
(click)="removeItem($index)"
[disabled]="!canRemoveItem"
[attr.aria-label]="'Remove ' + (arrayConfig().label | abpLocalization) + ' #' + ($index + 1)">
<i class="fa fa-trash me-1"></i>
{{ '::Remove' | abpLocalization }}
</button>
</div>
<!-- Item Fields -->
<div class="row">
@for (field of sortedChildren; track field.key) {
<div [ngClass]="'col-md-' + (field.gridSize || 12)">
<!-- Nested Group -->
@if (field.type === 'group') {
<abp-dynamic-form-group
[groupConfig]="field"
[formGroup]="getNestedFormGroup($index, field.key)" />
}
<!-- Nested Array (recursive) -->
@else if (field.type === 'array') {
<abp-dynamic-form-array
[arrayConfig]="field"
[formGroup]="getItemFormGroup($index)" />
}
<!-- Regular Field -->
@else {
<abp-dynamic-form-field
[field]="field"
[formControlName]="field.key" />
}
</div>
}
</div>
</div>
} @empty {
<div class="alert alert-info" role="status">
<i class="fa fa-info-circle me-2"></i>
{{ '::NoItemsAdded' | abpLocalization }}
</div>
}
</div>
<!-- Item Count -->
<div class="array-footer text-muted small" aria-live="polite" aria-atomic="true">
{{ formArray.length }} {{ '::Items' | abpLocalization }}
@if (arrayConfig().minItems) {
({{ '::Min' | abpLocalization }}: {{ arrayConfig().minItems }})
}
@if (arrayConfig().maxItems) {
({{ '::Max' | abpLocalization }}: {{ arrayConfig().maxItems }})
}
</div>
</div>
}

75
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.scss

@ -0,0 +1,75 @@
.form-array-container {
margin-bottom: 1.5rem;
}
.array-header {
border-bottom: 2px solid var(--bs-primary, #007bff);
padding-bottom: 0.5rem;
}
.form-array-label {
font-size: 1.1rem;
font-weight: 600;
color: var(--bs-dark, #212529);
margin-bottom: 0;
}
.array-items {
margin-top: 1rem;
}
.array-item {
background-color: var(--bs-white, #fff);
transition: all 0.2s ease;
position: relative;
&:hover {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
transform: translateY(-1px);
}
// Nested arrays get lighter background
.array-item {
background-color: var(--bs-light, #f8f9fa);
}
}
.item-header {
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
padding-bottom: 0.75rem;
}
.item-title {
color: var(--bs-primary, #007bff);
font-size: 0.95rem;
}
.array-footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--bs-border-color, #dee2e6);
}
// Accessibility: Focus styles for buttons
button {
&:focus-visible {
outline: 2px solid var(--bs-primary, #007bff);
outline-offset: 2px;
}
}
// Animation for add/remove
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.array-item {
animation: slideIn 0.3s ease;
}

88
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.ts

@ -0,0 +1,88 @@
import {
ChangeDetectionStrategy,
Component,
input,
inject,
ChangeDetectorRef,
forwardRef,
} from '@angular/core';
import { FormGroup, FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { FormFieldConfig } from '../dynamic-form.models';
import { DynamicFormService } from '../dynamic-form.service';
import { LocalizationPipe } from '@abp/ng.core';
import { DynamicFormFieldComponent } from '../dynamic-form-field';
import { DynamicFormGroupComponent } from '../dynamic-form-group';
@Component({
selector: 'abp-dynamic-form-array',
templateUrl: './dynamic-form-array.component.html',
styleUrls: ['./dynamic-form-array.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ReactiveFormsModule,
LocalizationPipe,
DynamicFormFieldComponent,
DynamicFormGroupComponent,
forwardRef(() => DynamicFormArrayComponent), // Self reference for recursion
],
})
export class DynamicFormArrayComponent {
arrayConfig = input.required<FormFieldConfig>();
formGroup = input.required<FormGroup>();
visible = input<boolean>(true);
private fb = inject(FormBuilder);
private dynamicFormService = inject(DynamicFormService);
private cdr = inject(ChangeDetectorRef);
get formArray(): FormArray {
return this.formGroup().get(this.arrayConfig().key) as FormArray;
}
get sortedChildren(): FormFieldConfig[] {
const children = this.arrayConfig().children || [];
return children.sort((a, b) => (a.order || 0) - (b.order || 0));
}
get canAddItem(): boolean {
const maxItems = this.arrayConfig().maxItems;
return maxItems ? this.formArray.length < maxItems : true;
}
get canRemoveItem(): boolean {
const minItems = this.arrayConfig().minItems || 0;
return this.formArray.length > minItems;
}
addItem() {
if (!this.canAddItem) return;
const itemGroup = this.dynamicFormService.createFormGroup(
this.arrayConfig().children || []
);
this.formArray.push(itemGroup);
this.cdr.markForCheck();
}
removeItem(index: number) {
if (!this.canRemoveItem) return;
this.formArray.removeAt(index);
this.cdr.markForCheck();
}
getItemFormGroup(index: number): FormGroup {
return this.formArray.at(index) as FormGroup;
}
getNestedFormGroup(index: number, key: string): FormGroup {
return this.getItemFormGroup(index).get(key) as FormGroup;
}
trackByIndex(index: number): number {
return index;
}
}

1
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/index.ts

@ -0,0 +1 @@
export * from './dynamic-form-array.component';

4
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html

@ -1,5 +1,9 @@
@if (visible()) {
<div [formGroup]="fieldFormGroup" role="group" [attr.aria-labelledby]="fieldId + '-label'">
<!-- NOTE: Group and Array types are NOT rendered here, they should be rendered at parent level -->
<!-- This component only handles leaf/primitive field types -->
@if (field().type === 'text') {
<!-- Text Input -->
<div class="form-group mb-3">

36
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.html

@ -0,0 +1,36 @@
@if (visible()) {
<fieldset class="form-group-container" [formGroup]="formGroup()" role="group" [attr.aria-labelledby]="groupConfig().key + '-legend'">
<legend [id]="groupConfig().key + '-legend'" class="form-group-legend">
{{ groupConfig().label | abpLocalization }}
</legend>
<div class="row">
@for (field of sortedChildren; track field.key) {
<div [ngClass]="'col-md-' + (field.gridSize || 12)">
<!-- Nested Group (Recursive) -->
@if (field.type === 'group') {
<abp-dynamic-form-group
[groupConfig]="field"
[formGroup]="getChildFormGroup(field.key)" />
}
<!-- Nested Array -->
@else if (field.type === 'array') {
<abp-dynamic-form-array
[arrayConfig]="field"
[formGroup]="formGroup()" />
}
<!-- Regular Field -->
@else {
<abp-dynamic-form-field
[field]="field"
[formControlName]="field.key" />
}
</div>
}
</div>
</fieldset>
}

26
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.scss

@ -0,0 +1,26 @@
.form-group-container {
border-left: 3px solid var(--bs-primary, #007bff);
padding-left: 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
padding: 1rem;
background-color: var(--bs-light, #f8f9fa);
// Nested groups get lighter styling
.form-group-container {
border-left-color: var(--bs-secondary, #6c757d);
padding-left: 0.75rem;
background-color: var(--bs-white, #fff);
}
}
.form-group-legend {
font-size: 1.1rem;
font-weight: 600;
color: var(--bs-primary, #007bff);
margin-bottom: 1rem;
padding: 0 0.5rem;
float: none;
width: auto;
}

46
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.ts

@ -0,0 +1,46 @@
import {
ChangeDetectionStrategy,
Component,
input,
inject,
forwardRef,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { FormFieldConfig } from '../dynamic-form.models';
import { LocalizationPipe } from '@abp/ng.core';
import { DynamicFormFieldComponent } from '../dynamic-form-field';
import { DynamicFormArrayComponent } from '../dynamic-form-array';
@Component({
selector: 'abp-dynamic-form-group',
templateUrl: './dynamic-form-group.component.html',
styleUrls: ['./dynamic-form-group.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ReactiveFormsModule,
LocalizationPipe,
DynamicFormFieldComponent,
forwardRef(() => DynamicFormArrayComponent),
forwardRef(() => DynamicFormGroupComponent), // Self reference for recursion
],
})
export class DynamicFormGroupComponent {
groupConfig = input.required<FormFieldConfig>();
formGroup = input.required<FormGroup>();
visible = input<boolean>(true);
get sortedChildren(): FormFieldConfig[] {
const children = this.groupConfig().children || [];
return children.sort((a, b) => (a.order || 0) - (b.order || 0));
}
getChildFormGroup(key: string): FormGroup {
return this.formGroup().get(key) as FormGroup;
}
getChildControl(key: string) {
return this.formGroup().get(key);
}
}

1
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/index.ts

@ -0,0 +1 @@
export * from './dynamic-form-group.component';

36
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.html

@ -8,19 +8,43 @@
<div class="row" role="group">
@for (field of sortedFields; track field.key) {
<div [ngClass]="'col-md-' + (field.gridSize || 12)">
<!-- Custom Component -->
@if (field.component) {
<abp-dynamic-form-field-host
[component]="field.component"
[inputs]="{ field: field, visible: isFieldVisible(field) }"
formControlName="{{ field.key }}">
</abp-dynamic-form-field-host>
} @else {
<abp-dynamic-form-field
[field]="field"
[formControlName]="field.key"
[visible]="isFieldVisible(field)">
</abp-dynamic-form-field>
}
<!-- Nested Group -->
@else if (field.type === 'group') {
<abp-dynamic-form-group
[groupConfig]="field"
[formGroup]="getChildFormGroup(field.key)"
[visible]="isFieldVisible(field)">
</abp-dynamic-form-group>
}
<!-- Nested Array -->
@else if (field.type === 'array') {
<abp-dynamic-form-array
[arrayConfig]="field"
[formGroup]="dynamicForm"
[visible]="isFieldVisible(field)">
</abp-dynamic-form-array>
}
<!-- Regular Field -->
@else {
<abp-dynamic-form-field
[field]="field"
[formControlName]="field.key"
[visible]="isFieldVisible(field)">
</abp-dynamic-form-field>
}
</div>
}
</div>

8
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts

@ -14,6 +14,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DynamicFormService } from './dynamic-form.service';
import { ConditionalAction, FormFieldConfig } from './dynamic-form.models';
import { DynamicFormFieldComponent, DynamicFieldHostComponent } from './dynamic-form-field';
import { DynamicFormGroupComponent } from './dynamic-form-group';
import { DynamicFormArrayComponent } from './dynamic-form-array';
@Component({
selector: 'abp-dynamic-form',
@ -25,6 +27,8 @@ import { DynamicFormFieldComponent, DynamicFieldHostComponent } from './dynamic-
imports: [
CommonModule,
DynamicFormFieldComponent,
DynamicFormGroupComponent,
DynamicFormArrayComponent,
ReactiveFormsModule,
DynamicFieldHostComponent,
],
@ -73,6 +77,10 @@ export class DynamicFormComponent implements OnInit {
return this.fieldVisibility[field.key] !== false;
}
getChildFormGroup(key: string): FormGroup {
return this.dynamicForm.get(key) as FormGroup;
}
resetForm() {
const initialValues: { [key: string]: any } = this.dynamicFormService.getInitialValues(
this.fields(),

6
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.models.ts

@ -4,7 +4,7 @@ import { ControlValueAccessor } from '@angular/forms';
export interface FormFieldConfig<T = any> {
key: string;
value?: any;
type: 'text' | 'email' | 'number' | 'select' | 'checkbox' | 'date' | 'textarea' | 'datetime-local' | 'time' | 'password' | 'tel' | 'url' | 'radio' | 'file' | 'range' | 'color';
type: 'text' | 'email' | 'number' | 'select' | 'checkbox' | 'date' | 'textarea' | 'datetime-local' | 'time' | 'password' | 'tel' | 'url' | 'radio' | 'file' | 'range' | 'color' | 'group' | 'array';
label: string;
placeholder?: string;
required?: boolean;
@ -24,6 +24,10 @@ export interface FormFieldConfig<T = any> {
pattern?: string; // For tel, text
accept?: string; // For file input (e.g., "image/*")
multiple?: boolean; // For file input
// Nested form support (for group and array types)
children?: FormFieldConfig[]; // Child fields for nested forms
minItems?: number; // For array type: minimum number of items
maxItems?: number; // For array type: maximum number of items
}
export interface ValidatorConfig {

51
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.service.ts

@ -1,5 +1,5 @@
import {Injectable, inject} from '@angular/core';
import {FormControl, FormGroup, ValidatorFn, Validators, FormBuilder} from '@angular/forms';
import {FormControl, FormGroup, FormArray, ValidatorFn, Validators, FormBuilder} from '@angular/forms';
import {FormFieldConfig, ValidatorConfig} from './dynamic-form.models';
import { RestService } from '@abp/ng.core';
@ -17,22 +17,51 @@ export class DynamicFormService {
const group: any = {};
fields.forEach(field => {
const validators = this.buildValidators(field.validators || []);
const initialValue = this.getInitialValue(field);
// Nested Group
if (field.type === 'group') {
group[field.key] = this.createFormGroup(field.children || []);
}
// Nested Array
else if (field.type === 'array') {
group[field.key] = this.createFormArray(field);
}
// Regular Field
else {
const validators = this.buildValidators(field.validators || []);
const initialValue = this.getInitialValue(field);
group[field.key] = new FormControl({
value: initialValue,
disabled: field.disabled || false
}, validators);
group[field.key] = new FormControl({
value: initialValue,
disabled: field.disabled || false
}, validators);
}
});
return this.formBuilder.group(group);
}
createFormArray(arrayConfig: FormFieldConfig): FormArray {
const items: FormGroup[] = [];
const minItems = arrayConfig.minItems || 0;
// Create minimum required items
for (let i = 0; i < minItems; i++) {
items.push(this.createFormGroup(arrayConfig.children || []));
}
return this.formBuilder.array(items);
}
getInitialValues(fields: FormFieldConfig[]): any {
const initialValues: any = {};
fields.forEach(field => {
initialValues[field.key] = this.getInitialValue(field);
if (field.type === 'group') {
initialValues[field.key] = this.getInitialValues(field.children || []);
} else if (field.type === 'array') {
initialValues[field.key] = [];
} else {
initialValues[field.key] = this.getInitialValue(field);
}
});
return initialValues;
}
@ -71,7 +100,7 @@ export class DynamicFormService {
}
private getInitialValue(field: FormFieldConfig): any {
if (field.value) {
if (field.value !== undefined) {
return field.value;
}
switch (field.type) {
@ -79,6 +108,10 @@ export class DynamicFormService {
return false;
case 'number':
return 0;
case 'group':
return this.getInitialValues(field.children || []);
case 'array':
return [];
default:
return '';
}

3
npm/ng-packs/packages/components/dynamic-form/src/public-api.ts

@ -1,3 +1,6 @@
export * from './dynamic-form.component';
export * from './dynamic-form-field';
export * from './dynamic-form.models';
export * from './dynamic-form.service';
export * from './dynamic-form-group';
export * from './dynamic-form-array';

Loading…
Cancel
Save