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.component'; |
||||
export * from './dynamic-form-field'; |
export * from './dynamic-form-field'; |
||||
export * from './dynamic-form.models'; |
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