mirror of https://github.com/abpframework/abp.git
17 changed files with 953 additions and 18 deletions
@ -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 ✅ |
|||
@ -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> |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './dynamic-form-array.component'; |
|||
@ -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> |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './dynamic-form-group.component'; |
|||
@ -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…
Reference in new issue