mirror of https://github.com/abpframework/abp.git
committed by
GitHub
32 changed files with 2634 additions and 96 deletions
@ -0,0 +1,111 @@ |
|||
<div class="container mt-4"> |
|||
<div class="row mb-4"> |
|||
<div class="col-12"> |
|||
<h1 class="display-4">Dynamic Form Showcase</h1> |
|||
<p class="lead text-muted"> |
|||
Comprehensive example demonstrating all available field types with validation and conditional logic. |
|||
</p> |
|||
<hr> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="row"> |
|||
<div class="col-lg-10 mx-auto"> |
|||
<div class="card shadow-sm"> |
|||
<div class="card-header bg-primary text-white"> |
|||
<h4 class="mb-0"> |
|||
<i class="fas fa-wpforms me-2"></i> |
|||
User Registration Form |
|||
</h4> |
|||
<small>All 16 field types + Nested Forms (Group & Array) with full accessibility support</small> |
|||
</div> |
|||
<div class="card-body p-4"> |
|||
@if (formFields.length) { |
|||
<abp-dynamic-form |
|||
[fields]="formFields" |
|||
[submitButtonText]="'Register'" |
|||
[showCancelButton]="true" |
|||
(onSubmit)="submit($event)" |
|||
(formCancel)="cancel()" /> |
|||
} @else { |
|||
<div class="text-center py-5"> |
|||
<div class="spinner-border text-primary" role="status"> |
|||
<span class="visually-hidden">Loading...</span> |
|||
</div> |
|||
<p class="mt-3 text-muted">Loading form configuration...</p> |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Field Types Reference Card --> |
|||
<div class="card shadow-sm mt-4"> |
|||
<div class="card-header bg-info text-white"> |
|||
<h5 class="mb-0"> |
|||
<i class="fas fa-info-circle me-2"></i> |
|||
Available Field Types |
|||
</h5> |
|||
</div> |
|||
<div class="card-body"> |
|||
<div class="row"> |
|||
<div class="col-md-6"> |
|||
<h6 class="text-primary">Text Inputs</h6> |
|||
<ul class="list-unstyled"> |
|||
<li><i class="fas fa-check text-success"></i> Text</li> |
|||
<li><i class="fas fa-check text-success"></i> Email</li> |
|||
<li><i class="fas fa-check text-success"></i> Password</li> |
|||
<li><i class="fas fa-check text-success"></i> Tel</li> |
|||
<li><i class="fas fa-check text-success"></i> URL</li> |
|||
<li><i class="fas fa-check text-success"></i> Textarea</li> |
|||
</ul> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<h6 class="text-primary">Special Inputs</h6> |
|||
<ul class="list-unstyled"> |
|||
<li><i class="fas fa-check text-success"></i> Number</li> |
|||
<li><i class="fas fa-check text-success"></i> Date</li> |
|||
<li><i class="fas fa-check text-success"></i> DateTime-Local</li> |
|||
<li><i class="fas fa-check text-success"></i> Time</li> |
|||
<li><i class="fas fa-check text-success"></i> Range</li> |
|||
<li><i class="fas fa-check text-success"></i> Color</li> |
|||
</ul> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<h6 class="text-primary">Selection</h6> |
|||
<ul class="list-unstyled"> |
|||
<li><i class="fas fa-check text-success"></i> Select (Dropdown)</li> |
|||
<li><i class="fas fa-check text-success"></i> Radio</li> |
|||
<li><i class="fas fa-check text-success"></i> Checkbox</li> |
|||
</ul> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<h6 class="text-primary">Files</h6> |
|||
<ul class="list-unstyled"> |
|||
<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, 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> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,38 @@ |
|||
import { Component, inject, OnInit, ViewChild } from '@angular/core'; |
|||
import { DynamicFormComponent, FormFieldConfig } from '@abp/ng.components/dynamic-form'; |
|||
import { FormConfigService } from './form-config.service'; |
|||
|
|||
@Component({ |
|||
selector: 'app-dynamic-form-page', |
|||
templateUrl: './dynamic-form-page.component.html', |
|||
imports: [DynamicFormComponent], |
|||
}) |
|||
export class DynamicFormPageComponent implements OnInit { |
|||
@ViewChild(DynamicFormComponent, { static: false }) dynamicFormComponent: DynamicFormComponent; |
|||
protected readonly formConfigService = inject(FormConfigService); |
|||
|
|||
formFields: FormFieldConfig[] = []; |
|||
|
|||
ngOnInit() { |
|||
this.formConfigService.getFormConfig().subscribe(config => { |
|||
this.formFields = config; |
|||
}); |
|||
} |
|||
|
|||
submit(formData: any) { |
|||
console.log('✅ Form Submitted Successfully!', formData); |
|||
console.table(formData); |
|||
|
|||
// Show success message
|
|||
alert('✅ Form submitted successfully! Check the console for details.'); |
|||
|
|||
// Reset form after submission
|
|||
this.dynamicFormComponent.resetForm(); |
|||
} |
|||
|
|||
cancel() { |
|||
console.log('❌ Form Cancelled'); |
|||
alert('Form cancelled'); |
|||
this.dynamicFormComponent.resetForm(); |
|||
} |
|||
} |
|||
@ -0,0 +1,381 @@ |
|||
import { Injectable } from '@angular/core'; |
|||
import { FormFieldConfig } from '@abp/ng.components/dynamic-form'; |
|||
import { Observable, of } from 'rxjs'; |
|||
|
|||
@Injectable({ |
|||
providedIn: 'root', |
|||
}) |
|||
export class FormConfigService { |
|||
getFormConfig(): Observable<FormFieldConfig[]> { |
|||
const formConfig: FormFieldConfig[] = [ |
|||
// Section 1: Basic Text Inputs
|
|||
{ |
|||
key: 'firstName', |
|||
label: 'First Name', |
|||
type: 'text', |
|||
placeholder: 'Enter your first name', |
|||
required: true, |
|||
gridSize: 6, |
|||
order: 1, |
|||
validators: [{ type: 'required', message: 'First name is required' }], |
|||
}, |
|||
{ |
|||
key: 'lastName', |
|||
label: 'Last Name', |
|||
type: 'text', |
|||
placeholder: 'Enter your last name', |
|||
required: true, |
|||
gridSize: 6, |
|||
order: 2, |
|||
validators: [{ type: 'required', message: 'Last name is required' }], |
|||
}, |
|||
|
|||
// Section 2: Email & Password
|
|||
{ |
|||
key: 'email', |
|||
label: 'Email Address', |
|||
type: 'email', |
|||
placeholder: 'example@domain.com', |
|||
required: true, |
|||
gridSize: 6, |
|||
order: 3, |
|||
validators: [ |
|||
{ type: 'required', message: 'Email is required' }, |
|||
{ type: 'email', message: 'Invalid email address' }, |
|||
], |
|||
}, |
|||
{ |
|||
key: 'password', |
|||
label: 'Password', |
|||
type: 'password', |
|||
placeholder: 'Enter a strong password', |
|||
required: true, |
|||
gridSize: 6, |
|||
order: 4, |
|||
minLength: 8, |
|||
maxLength: 50, |
|||
validators: [ |
|||
{ type: 'required', message: 'Password is required' }, |
|||
{ type: 'minLength', value: 8, message: 'Password must be at least 8 characters' }, |
|||
], |
|||
}, |
|||
|
|||
// Section 3: Contact Information
|
|||
{ |
|||
key: 'phone', |
|||
label: 'Phone Number', |
|||
type: 'tel', |
|||
placeholder: '555-123-4567', |
|||
gridSize: 6, |
|||
order: 5, |
|||
pattern: '[0-9]{3}-[0-9]{3}-[0-9]{4}', |
|||
}, |
|||
{ |
|||
key: 'website', |
|||
label: 'Website', |
|||
type: 'url', |
|||
placeholder: 'https://example.com', |
|||
gridSize: 6, |
|||
order: 6, |
|||
}, |
|||
|
|||
// Section 4: Numbers & Dates
|
|||
{ |
|||
key: 'age', |
|||
label: 'Age', |
|||
type: 'number', |
|||
placeholder: 'Enter your age', |
|||
required: true, |
|||
gridSize: 4, |
|||
order: 7, |
|||
min: 18, |
|||
max: 100, |
|||
validators: [ |
|||
{ type: 'required', message: 'Age is required' }, |
|||
{ type: 'min', value: 18, message: 'You must be at least 18 years old' }, |
|||
], |
|||
}, |
|||
{ |
|||
key: 'birthdate', |
|||
label: 'Birth Date', |
|||
type: 'date', |
|||
required: true, |
|||
gridSize: 4, |
|||
order: 8, |
|||
max: new Date().toISOString().split('T')[0], |
|||
validators: [{ type: 'required', message: 'Birth date is required' }], |
|||
}, |
|||
{ |
|||
key: 'appointmentTime', |
|||
label: 'Appointment Date & Time', |
|||
type: 'datetime-local', |
|||
gridSize: 4, |
|||
order: 9, |
|||
min: new Date().toISOString().slice(0, 16), |
|||
}, |
|||
|
|||
// Section 5: Select & Radio
|
|||
{ |
|||
key: 'country', |
|||
label: 'Country', |
|||
type: 'select', |
|||
required: true, |
|||
gridSize: 6, |
|||
order: 10, |
|||
options: { |
|||
defaultValues: [ |
|||
{ key: 'usa', value: 'United States' }, |
|||
{ key: 'uk', value: 'United Kingdom' }, |
|||
{ key: 'canada', value: 'Canada' }, |
|||
{ key: 'germany', value: 'Germany' }, |
|||
{ key: 'france', value: 'France' }, |
|||
], |
|||
}, |
|||
validators: [{ type: 'required', message: 'Country is required' }], |
|||
}, |
|||
{ |
|||
key: 'gender', |
|||
label: 'Gender', |
|||
type: 'radio', |
|||
required: true, |
|||
gridSize: 6, |
|||
order: 11, |
|||
options: { |
|||
defaultValues: [ |
|||
{ key: 'male', value: 'Male' }, |
|||
{ key: 'female', value: 'Female' }, |
|||
{ key: 'other', value: 'Other' }, |
|||
{ key: 'prefer-not-to-say', value: 'Prefer not to say' }, |
|||
], |
|||
}, |
|||
validators: [{ type: 'required', message: 'Gender is required' }], |
|||
}, |
|||
|
|||
// Section 6: Conditional Field (shown when country is USA)
|
|||
{ |
|||
key: 'state', |
|||
label: 'State (USA Only)', |
|||
type: 'select', |
|||
gridSize: 6, |
|||
order: 12, |
|||
options: { |
|||
defaultValues: [ |
|||
{ key: 'ca', value: 'California' }, |
|||
{ key: 'ny', value: 'New York' }, |
|||
{ key: 'tx', value: 'Texas' }, |
|||
{ key: 'fl', value: 'Florida' }, |
|||
], |
|||
}, |
|||
conditionalLogic: [ |
|||
{ dependsOn: 'country', condition: 'equals', value: 'usa', action: 'show' }, |
|||
], |
|||
}, |
|||
|
|||
// Section 7: Time & Range
|
|||
{ |
|||
key: 'preferredTime', |
|||
label: 'Preferred Contact Time', |
|||
type: 'time', |
|||
gridSize: 6, |
|||
order: 13, |
|||
step: '900', // 15 minutes
|
|||
}, |
|||
{ |
|||
key: 'experienceLevel', |
|||
label: 'Experience Level (0-10)', |
|||
type: 'range', |
|||
gridSize: 6, |
|||
order: 14, |
|||
min: 0, |
|||
max: 10, |
|||
step: 1, |
|||
value: 5, |
|||
}, |
|||
|
|||
// Section 8: Color & File
|
|||
{ |
|||
key: 'favoriteColor', |
|||
label: 'Favorite Color', |
|||
type: 'color', |
|||
gridSize: 6, |
|||
order: 15, |
|||
value: '#007bff', |
|||
}, |
|||
{ |
|||
key: 'profilePicture', |
|||
label: 'Profile Picture', |
|||
type: 'file', |
|||
gridSize: 6, |
|||
order: 16, |
|||
accept: 'image/*', |
|||
multiple: false, |
|||
}, |
|||
|
|||
// Section 9: Textarea & Checkbox
|
|||
{ |
|||
key: 'bio', |
|||
label: 'Biography', |
|||
type: 'textarea', |
|||
placeholder: 'Tell us about yourself...', |
|||
gridSize: 12, |
|||
order: 17, |
|||
maxLength: 500, |
|||
}, |
|||
{ |
|||
key: 'newsletter', |
|||
label: 'Subscribe to newsletter', |
|||
type: 'checkbox', |
|||
gridSize: 6, |
|||
order: 18, |
|||
}, |
|||
{ |
|||
key: 'terms', |
|||
label: 'I agree to the terms and conditions', |
|||
type: 'checkbox', |
|||
required: true, |
|||
gridSize: 6, |
|||
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); |
|||
} |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
[ |
|||
{ |
|||
"key": "firstName", |
|||
"type": "text", |
|||
"label": "First Name", |
|||
"placeholder": "Enter first name", |
|||
"value": "erdemc", |
|||
"required": true, |
|||
"validators": [ |
|||
{ "type": "required", "message": "First name is required" }, |
|||
{ "type": "minLength", "value": 2, "message": "Minimum 2 characters required" } |
|||
], |
|||
"gridSize": 6, |
|||
"order": 1 |
|||
}, |
|||
{ |
|||
"key": "lastName", |
|||
"type": "text", |
|||
"label": "Last Name", |
|||
"placeholder": "Enter last name", |
|||
"required": true, |
|||
"validators": [{ "type": "required", "message": "Last name is required" }], |
|||
"gridSize": 12, |
|||
"order": 3 |
|||
}, |
|||
{ |
|||
"key": "email", |
|||
"type": "email", |
|||
"label": "AbpAccount::EmailAddress", |
|||
"placeholder": "Enter email", |
|||
"required": true, |
|||
"validators": [ |
|||
{ "type": "required", "message": "Email is required" }, |
|||
{ "type": "email", "message": "Please enter a valid email" } |
|||
], |
|||
"gridSize": 6, |
|||
"order": 2 |
|||
}, |
|||
{ |
|||
"key": "userType", |
|||
"type": "select", |
|||
"label": "User Type", |
|||
"required": true, |
|||
"options": [ |
|||
{ "key": "admin", "value": "Administrator" }, |
|||
{ "key": "user", "value": "Regular User" }, |
|||
{ "key": "guest", "value": "Guest User" } |
|||
], |
|||
"validators": [{ "type": "required", "message": "Please select user type" }], |
|||
"order": 4 |
|||
}, |
|||
{ |
|||
"key": "adminNotes", |
|||
"type": "textarea", |
|||
"label": "Admin Notes", |
|||
"placeholder": "Enter admin-specific notes", |
|||
"conditionalLogic": [ |
|||
{ |
|||
"dependsOn": "userType", |
|||
"condition": "equals", |
|||
"value": "admin", |
|||
"action": "show" |
|||
} |
|||
], |
|||
"order": 5 |
|||
}, |
|||
{ |
|||
"key": "isSelected", |
|||
"type": "checkbox", |
|||
"label": "Is Selected", |
|||
"order": 6 |
|||
} |
|||
] |
|||
@ -0,0 +1,338 @@ |
|||
# 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 |
|||
|
|||
@ -0,0 +1,6 @@ |
|||
{ |
|||
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", |
|||
"lib": { |
|||
"entryFile": "src/public-api.ts" |
|||
} |
|||
} |
|||
@ -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,135 @@ |
|||
import { |
|||
Component, |
|||
ViewChild, |
|||
ViewContainerRef, |
|||
ChangeDetectionStrategy, |
|||
forwardRef, |
|||
Type, |
|||
effect, |
|||
DestroyRef, |
|||
inject, |
|||
input, |
|||
} from '@angular/core'; |
|||
import { |
|||
ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl, ReactiveFormsModule |
|||
} from '@angular/forms'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
|
|||
type controlValueAccessorLike = Partial<ControlValueAccessor> & { setDisabledState?(d: boolean): void }; |
|||
type acceptsFormControl = { formControl?: FormControl }; |
|||
|
|||
@Component({ |
|||
selector: 'abp-dynamic-form-field-host', |
|||
imports: [CommonModule, ReactiveFormsModule], |
|||
template: `<ng-template #vcRef></ng-template>`, |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
providers: [{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => DynamicFieldHostComponent), |
|||
multi: true |
|||
}] |
|||
}) |
|||
export class DynamicFieldHostComponent implements ControlValueAccessor { |
|||
component = input<Type<ControlValueAccessor>>(); |
|||
inputs = input<Record<string, any>>({}); |
|||
|
|||
@ViewChild('vcRef', { read: ViewContainerRef, static: true }) viewContainerRef!: ViewContainerRef; |
|||
private componentRef?: any; |
|||
|
|||
private value: any; |
|||
private disabled = false; |
|||
|
|||
// if child has not implemented ControlValueAccessor. Create form control
|
|||
private innerControl = new FormControl<any>(null); |
|||
readonly destroyRef = inject(DestroyRef); |
|||
|
|||
constructor() { |
|||
effect(() => { |
|||
if (this.component()) { |
|||
this.createChild(); |
|||
} else if (this.componentRef && this.inputs()) { |
|||
this.applyInputs(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private createChild() { |
|||
this.viewContainerRef.clear(); |
|||
if (!this.component()) return; |
|||
|
|||
this.componentRef = this.viewContainerRef.createComponent(this.component()); |
|||
this.applyInputs(); |
|||
|
|||
const instance: any = this.componentRef.instance as controlValueAccessorLike & acceptsFormControl; |
|||
|
|||
if (this.isCVA(instance)) { |
|||
// Child CVA ise wrapper -> child delege
|
|||
instance.registerOnChange?.((v: any) => this.onChange(v)); |
|||
instance.registerOnTouched?.(() => this.onTouched()); |
|||
if (this.disabled && instance.setDisabledState) { |
|||
instance.setDisabledState(true); |
|||
} |
|||
// set initial value
|
|||
if (this.value !== undefined) { |
|||
instance.writeValue?.(this.value); |
|||
} |
|||
} else { |
|||
// No CVA -> use form control
|
|||
if ('formControl' in instance) { |
|||
instance.formControl = this.innerControl; |
|||
// apply initial value/disabled state
|
|||
if (this.value !== undefined) { |
|||
this.innerControl.setValue(this.value, { emitEvent: false }); |
|||
} |
|||
this.innerControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(v => this.onChange(v)); |
|||
this.innerControl.disabled ? null : (this.disabled && this.innerControl.disable({ emitEvent: false })); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private applyInputs() { |
|||
if (!this.componentRef) return; |
|||
const inst = this.componentRef.instance; |
|||
for (const [k, v] of Object.entries(this.inputs ?? {})) { |
|||
inst[k] = v; |
|||
} |
|||
this.componentRef.changeDetectorRef?.markForCheck?.(); |
|||
} |
|||
|
|||
private isCVA(obj: any): obj is controlValueAccessorLike { |
|||
return obj && typeof obj.writeValue === 'function' && typeof obj.registerOnChange === 'function'; |
|||
} |
|||
|
|||
writeValue(obj: any): void { |
|||
this.value = obj; |
|||
if (!this.componentRef) return; |
|||
|
|||
const inst: any = this.componentRef.instance as controlValueAccessorLike & acceptsFormControl; |
|||
|
|||
if (this.isCVA(inst)) { |
|||
inst.writeValue?.(obj); |
|||
} else if ('formControl' in inst && inst.formControl instanceof FormControl) { |
|||
inst.formControl.setValue(obj, { emitEvent: false }); |
|||
} |
|||
} |
|||
|
|||
private onChange: (v: any) => void = () => {}; |
|||
private onTouched: () => void = () => {}; |
|||
|
|||
registerOnChange(fn: any): void { this.onChange = fn; } |
|||
registerOnTouched(fn: any): void { this.onTouched = fn; } |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (!this.componentRef) return; |
|||
|
|||
const inst = this.componentRef.instance as controlValueAccessorLike & acceptsFormControl; |
|||
|
|||
if (this.isCVA(inst) && inst.setDisabledState) { |
|||
inst.setDisabledState(isDisabled); |
|||
} else if ('formControl' in inst && inst.formControl instanceof FormControl) { |
|||
isDisabled ? inst.formControl.disable({ emitEvent: false }) : inst.formControl.enable({ emitEvent: false }); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,371 @@ |
|||
@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"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
[id]="fieldId" |
|||
[placeholder]="(field().placeholder || '') | abpLocalization" |
|||
formControlName="value" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'select') { |
|||
<!-- Select Dropdown --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<select |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
<option value="">{{ '::Select' | abpLocalization }}</option> |
|||
@for (option of options$ | async; track option.key) { |
|||
<option [value]="option.key"> |
|||
{{ option.value | abpLocalization }} |
|||
</option> |
|||
} |
|||
</select> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'checkbox') { |
|||
<!-- Checkbox --> |
|||
<div class="form-group form-check mb-3" role="group"> |
|||
<abp-checkbox |
|||
[label]="field().label | abpLocalization" |
|||
formControlName="value" |
|||
[id]="fieldId" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" /> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'email') { |
|||
<!-- Email Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="email" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[placeholder]="(field().placeholder || '') | abpLocalization" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization" |
|||
autocomplete="email"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'textarea') { |
|||
<!-- Textarea --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<textarea |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[placeholder]="(field().placeholder || '') | abpLocalization" |
|||
[class.is-invalid]="isInvalid" |
|||
rows="4" |
|||
class="form-control" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
</textarea> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'number') { |
|||
<!-- Number Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="number" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[placeholder]="(field().placeholder || '') | abpLocalization" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.min]="field().min || null" |
|||
[attr.max]="field().max || null" |
|||
[attr.step]="field().step || null" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'date') { |
|||
<!-- Date Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="date" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.min]="field().min || null" |
|||
[attr.max]="field().max || null" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'datetime-local') { |
|||
<!-- DateTime Local Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="datetime-local" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.min]="field().min || null" |
|||
[attr.max]="field().max || null" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'time') { |
|||
<!-- Time Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="time" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.min]="field().min || null" |
|||
[attr.max]="field().max || null" |
|||
[attr.step]="field().step || null" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'password') { |
|||
<!-- Password Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="password" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[placeholder]="(field().placeholder || '') | abpLocalization" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.minlength]="field().minLength || null" |
|||
[attr.maxlength]="field().maxLength || null" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization" |
|||
autocomplete="new-password"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'tel') { |
|||
<!-- Tel Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="tel" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[placeholder]="(field().placeholder || '') | abpLocalization" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.pattern]="field().pattern || null" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization" |
|||
autocomplete="tel"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'url') { |
|||
<!-- URL Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="url" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[placeholder]="(field().placeholder || '') | abpLocalization" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization" |
|||
autocomplete="url"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'radio') { |
|||
<!-- Radio Group --> |
|||
<div class="form-group mb-3" role="radiogroup" [attr.aria-labelledby]="fieldId + '-label'"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<div class="radio-group"> |
|||
@for (option of options$ | async; track option.key) { |
|||
<div class="form-check"> |
|||
<input |
|||
type="radio" |
|||
[id]="fieldId + '-' + option.key" |
|||
[value]="option.key" |
|||
formControlName="value" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-check-input" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null"> |
|||
<label [for]="fieldId + '-' + option.key" class="form-check-label"> |
|||
{{ option.value | abpLocalization }} |
|||
</label> |
|||
</div> |
|||
} |
|||
</div> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'file') { |
|||
<!-- File Input --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<input |
|||
type="file" |
|||
[id]="fieldId" |
|||
(change)="onFileChange($event)" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control" |
|||
[attr.accept]="field().accept || null" |
|||
[attr.multiple]="field().multiple || null" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'range') { |
|||
<!-- Range Slider --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<div class="d-flex align-items-center gap-3"> |
|||
<input |
|||
type="range" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-range flex-grow-1" |
|||
[attr.min]="field().min || 0" |
|||
[attr.max]="field().max || 100" |
|||
[attr.step]="field().step || 1" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization" |
|||
[attr.aria-valuenow]="value.value" |
|||
[attr.aria-valuemin]="field().min || 0" |
|||
[attr.aria-valuemax]="field().max || 100"> |
|||
<output [for]="fieldId" class="badge bg-primary">{{ value.value }}</output> |
|||
</div> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} @else if (field().type === 'color') { |
|||
<!-- Color Picker --> |
|||
<div class="form-group mb-3"> |
|||
<ng-container [ngTemplateOutlet]="labelTemplate" /> |
|||
<div class="d-flex align-items-center gap-3"> |
|||
<input |
|||
type="color" |
|||
[id]="fieldId" |
|||
formControlName="value" |
|||
[class.is-invalid]="isInvalid" |
|||
class="form-control form-control-color" |
|||
[attr.aria-required]="field().required || null" |
|||
[attr.aria-invalid]="isInvalid || null" |
|||
[attr.aria-describedby]="isInvalid ? errorId : null" |
|||
[attr.aria-label]="field().label | abpLocalization"> |
|||
<code class="text-muted">{{ value.value || '#000000' }}</code> |
|||
</div> |
|||
@if (isInvalid) { |
|||
<ng-container [ngTemplateOutlet]="errorTemplate" /> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
|
|||
<ng-template #labelTemplate> |
|||
<label |
|||
[for]="fieldId" |
|||
[id]="fieldId + '-label'" |
|||
[class.required]="field().required"> |
|||
{{ field().label | abpLocalization }} |
|||
@if (field().required) { |
|||
<span class="text-danger" aria-label="required">*</span> |
|||
} |
|||
</label> |
|||
</ng-template> |
|||
|
|||
<ng-template #errorTemplate> |
|||
<div |
|||
class="invalid-feedback" |
|||
[id]="errorId" |
|||
role="alert" |
|||
aria-live="polite" |
|||
aria-atomic="true"> |
|||
@for (error of errors; track error) { |
|||
<div>{{ error | abpLocalization }}</div> |
|||
} |
|||
</div> |
|||
</ng-template> |
|||
@ -0,0 +1,12 @@ |
|||
// Minimal styling - rely on Bootstrap/Lepton-X theme styles |
|||
.form-group { |
|||
display: flex; |
|||
flex-direction: column; |
|||
|
|||
// Radio group spacing (layout only) |
|||
.radio-group { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.5rem; |
|||
} |
|||
} |
|||
@ -0,0 +1,180 @@ |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
DestroyRef, |
|||
forwardRef, |
|||
inject, |
|||
InjectionToken, Injector, |
|||
input, |
|||
OnInit, |
|||
} from '@angular/core'; |
|||
import { FormFieldConfig } from '../dynamic-form.models'; |
|||
import { |
|||
ControlValueAccessor, |
|||
FormBuilder, |
|||
FormControl, |
|||
FormControlName, |
|||
FormGroupDirective, |
|||
NG_VALUE_ACCESSOR, |
|||
NgControl, |
|||
FormGroup, |
|||
ReactiveFormsModule, |
|||
} from '@angular/forms'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { NgTemplateOutlet, AsyncPipe } from '@angular/common'; |
|||
import { LocalizationPipe } from '@abp/ng.core'; |
|||
import { FormCheckboxComponent } from '@abp/ng.theme.shared'; |
|||
import { Observable, of } from 'rxjs'; |
|||
import { DynamicFormService } from '../dynamic-form.service'; |
|||
|
|||
export const ABP_DYNAMIC_FORM_FIELD = new InjectionToken<DynamicFormFieldComponent>('AbpDynamicFormField'); |
|||
|
|||
const DYNAMIC_FORM_FIELD_CONTROL_VALUE_ACCESSOR = { |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => DynamicFormFieldComponent), |
|||
multi: true, |
|||
}; |
|||
|
|||
@Component({ |
|||
selector: 'abp-dynamic-form-field', |
|||
templateUrl: './dynamic-form-field.component.html', |
|||
styleUrls: ['./dynamic-form-field.component.scss'], |
|||
providers: [ |
|||
{ provide: ABP_DYNAMIC_FORM_FIELD, useExisting: DynamicFormFieldComponent }, |
|||
DYNAMIC_FORM_FIELD_CONTROL_VALUE_ACCESSOR, |
|||
], |
|||
host: { class: 'abp-dynamic-form-field' }, |
|||
exportAs: 'abpDynamicFormField', |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
imports: [NgTemplateOutlet, LocalizationPipe, ReactiveFormsModule, FormCheckboxComponent, AsyncPipe], |
|||
}) |
|||
export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor { |
|||
field = input.required<FormFieldConfig>(); |
|||
visible = input<boolean>(true); |
|||
control!: FormControl; |
|||
fieldFormGroup: FormGroup; |
|||
readonly changeDetectorRef = inject(ChangeDetectorRef); |
|||
readonly destroyRef = inject(DestroyRef); |
|||
private injector = inject(Injector); |
|||
private formBuilder = inject(FormBuilder); |
|||
private dynamicFormService = inject(DynamicFormService); |
|||
|
|||
options$: Observable<{ key: string; value: any }[]> = of([]); |
|||
|
|||
// Accessibility: Generate unique IDs for ARIA
|
|||
get fieldId(): string { |
|||
return `field-${this.field().key}`; |
|||
} |
|||
|
|||
get errorId(): string { |
|||
return `${this.fieldId}-error`; |
|||
} |
|||
|
|||
get helpTextId(): string { |
|||
return `${this.fieldId}-help`; |
|||
} |
|||
|
|||
constructor() { |
|||
this.fieldFormGroup = this.formBuilder.group({ |
|||
value: [{ value: '' }], |
|||
}); |
|||
} |
|||
|
|||
ngOnInit() { |
|||
const ngControl = this.injector.get(NgControl, null); |
|||
if (ngControl) { |
|||
this.control = this.injector.get(FormGroupDirective).getControl(ngControl as FormControlName); |
|||
} |
|||
this.value.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { |
|||
this.onChange(value); |
|||
}); |
|||
|
|||
const options = this.field().options; |
|||
|
|||
if (options?.url) { |
|||
this.options$ = this.dynamicFormService.getOptions(options.url, options.apiName); |
|||
} else if (options?.defaultValues?.length) { |
|||
this.options$ = of( |
|||
options.defaultValues.map(item => { |
|||
return { |
|||
key: item[options.valueProp || 'key'] || item, |
|||
value: item[options.labelProp || 'value'] || item |
|||
}; |
|||
}) |
|||
); |
|||
} else { |
|||
this.options$ = of([]); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: any[]): void { |
|||
this.value.setValue(value || ''); |
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.onChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
this.onTouched = fn; |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
if (isDisabled) { |
|||
this.value.disable(); |
|||
} else { |
|||
this.value.enable(); |
|||
} |
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
|
|||
get isInvalid(): boolean { |
|||
if (this.control) { |
|||
return this.control.invalid && (this.control.dirty || this.control.touched); |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
get errors(): string[] { |
|||
if (!this.control?.errors) return []; |
|||
if (this.control && this.control.errors) { |
|||
const errorKeys = Object.keys(this.control.errors); |
|||
const validators = this.field().validators || []; |
|||
return errorKeys.map(key => { |
|||
const validator = validators.find( |
|||
v => v.type.toLowerCase() === key.toLowerCase(), |
|||
); |
|||
if (validator && validator.message) { |
|||
return validator.message; |
|||
} |
|||
// Fallback error messages
|
|||
if (key === 'required') return `${this.field().label} is required`; |
|||
if (key === 'email') return 'Please enter a valid email address'; |
|||
if (key === 'minlength') |
|||
return `Minimum length is ${this.control.errors[key].requiredLength}`; |
|||
if (key === 'maxlength') |
|||
return `Maximum length is ${this.control.errors[key].requiredLength}`; |
|||
return `${this.field().label} is invalid due to ${key} validation.`; |
|||
}); |
|||
} |
|||
return []; |
|||
} |
|||
get value() { |
|||
return this.fieldFormGroup.get('value'); |
|||
} |
|||
|
|||
onFileChange(event: Event) { |
|||
const input = event.target as HTMLInputElement; |
|||
if (input.files) { |
|||
const files = Array.from(input.files); |
|||
const value = this.field().multiple ? files : files[0]; |
|||
this.value.setValue(value); |
|||
this.onChange(value); |
|||
} |
|||
} |
|||
|
|||
private onChange: (value: any) => void = () => { }; |
|||
private onTouched: () => void = () => { }; |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
export * from './dynamic-form-field.component'; |
|||
export * from './dynamic-form-field-host.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,45 @@ |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
input, |
|||
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'; |
|||
@ -0,0 +1,78 @@ |
|||
<div class="form-wrapper"> |
|||
<form |
|||
[formGroup]="dynamicForm" |
|||
(ngSubmit)="submit()" |
|||
class="dynamic-form" |
|||
role="form" |
|||
[attr.aria-label]="'Dynamic Form'"> |
|||
<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> |
|||
} |
|||
|
|||
<!-- 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> |
|||
<ng-content select="[actions]"> |
|||
<ng-container [ngTemplateOutlet]="defaultActions"></ng-container> |
|||
</ng-content> |
|||
|
|||
</form> |
|||
|
|||
<ng-template #defaultActions> |
|||
<div class="form-actions" role="group" aria-label="Form actions"> |
|||
@if (showCancelButton()) { |
|||
<button |
|||
type="button" |
|||
class="btn btn-secondary" |
|||
(click)="onCancel()" |
|||
aria-label="Cancel form"> |
|||
Cancel |
|||
</button> |
|||
} |
|||
<button |
|||
type="submit" |
|||
class="btn btn-primary" |
|||
[disabled]="!dynamicForm.valid || submitInProgress()" |
|||
[attr.aria-busy]="submitInProgress() || null" |
|||
[attr.aria-label]="submitInProgress() ? 'Submitting form...' : submitButtonText()"> |
|||
{{ submitButtonText() }} |
|||
</button> |
|||
</div> |
|||
</ng-template> |
|||
</div> |
|||
@ -0,0 +1,15 @@ |
|||
:host(.abp-dynamic-form) { |
|||
form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.5rem; |
|||
} |
|||
.form-wrapper { |
|||
text-align: left; |
|||
} |
|||
} |
|||
.form-actions { |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
gap: 0.5rem; |
|||
} |
|||
@ -0,0 +1,203 @@ |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
input, |
|||
output, |
|||
inject, |
|||
OnInit, |
|||
DestroyRef, |
|||
ChangeDetectorRef, |
|||
} from '@angular/core'; |
|||
import { FormGroup, ReactiveFormsModule } from '@angular/forms'; |
|||
import { CommonModule } from '@angular/common'; |
|||
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', |
|||
templateUrl: './dynamic-form.component.html', |
|||
styleUrls: ['./dynamic-form.component.scss'], |
|||
host: { class: 'abp-dynamic-form' }, |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
exportAs: 'abpDynamicForm', |
|||
imports: [ |
|||
CommonModule, |
|||
DynamicFormFieldComponent, |
|||
DynamicFormGroupComponent, |
|||
DynamicFormArrayComponent, |
|||
ReactiveFormsModule, |
|||
DynamicFieldHostComponent, |
|||
], |
|||
}) |
|||
export class DynamicFormComponent implements OnInit { |
|||
fields = input<FormFieldConfig[]>([]); |
|||
values = input<Record<string, any>>(); |
|||
submitButtonText = input<string>('Submit'); |
|||
submitInProgress = input<boolean>(false); |
|||
showCancelButton = input<boolean>(false); |
|||
onSubmit = output<any>(); |
|||
formCancel = output<void>(); |
|||
private dynamicFormService = inject(DynamicFormService); |
|||
readonly destroyRef = inject(DestroyRef); |
|||
readonly changeDetectorRef = inject(ChangeDetectorRef); |
|||
|
|||
dynamicForm!: FormGroup; |
|||
fieldVisibility: { [key: string]: boolean } = {}; |
|||
|
|||
ngOnInit() { |
|||
this.setupFormAndLogic(); |
|||
} |
|||
|
|||
get sortedFields(): FormFieldConfig[] { |
|||
return this.fields().sort((a, b) => (a.order || 0) - (b.order || 0)); |
|||
} |
|||
|
|||
submit() { |
|||
if (this.dynamicForm.valid) { |
|||
this.onSubmit.emit(this.dynamicForm.getRawValue()); |
|||
} else { |
|||
this.markAllFieldsAsTouched(); |
|||
this.focusFirstInvalidField(); |
|||
} |
|||
} |
|||
|
|||
onCancel() { |
|||
this.formCancel.emit(); |
|||
} |
|||
|
|||
onFieldChange(event: { fieldKey: string; value: any }) { |
|||
this.evaluateConditionalLogic(event.fieldKey); |
|||
} |
|||
|
|||
isFieldVisible(field: FormFieldConfig): boolean { |
|||
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(), |
|||
); |
|||
this.dynamicForm.reset({ ...initialValues }); |
|||
this.dynamicForm.markAsUntouched(); |
|||
this.dynamicForm.markAsPristine(); |
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
|
|||
private initializeFieldVisibility() { |
|||
this.fields().forEach(field => { |
|||
this.fieldVisibility = { |
|||
...this.fieldVisibility, |
|||
[field.key]: !field.conditionalLogic?.length, |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
private setupConditionalLogic() { |
|||
this.fields().forEach(field => { |
|||
if (field.conditionalLogic) { |
|||
field.conditionalLogic.forEach(rule => { |
|||
const dependentControl = this.dynamicForm.get(rule.dependsOn); |
|||
if (dependentControl) { |
|||
this.evaluateConditionalLogic(field.key); |
|||
dependentControl.valueChanges |
|||
.pipe(takeUntilDestroyed(this.destroyRef)) |
|||
.subscribe(() => { |
|||
this.evaluateConditionalLogic(field.key); |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private evaluateConditionalLogic(fieldKey: string) { |
|||
const field = this.fields().find(f => f.key === fieldKey); |
|||
if (!field?.conditionalLogic) return; |
|||
|
|||
field.conditionalLogic.forEach(rule => { |
|||
const dependentValue = this.dynamicForm.get(rule.dependsOn)?.value; |
|||
const conditionMet = this.evaluateCondition(dependentValue, rule.condition, rule.value); |
|||
|
|||
this.applyConditionalAction(fieldKey, rule.action, conditionMet); |
|||
}); |
|||
} |
|||
|
|||
private evaluateCondition(fieldValue: any, condition: string, ruleValue: any): boolean { |
|||
switch (condition) { |
|||
case 'equals': |
|||
return fieldValue === ruleValue; |
|||
case 'notEquals': |
|||
return fieldValue !== ruleValue; |
|||
case 'contains': |
|||
return fieldValue && fieldValue.includes && fieldValue.includes(ruleValue); |
|||
case 'greaterThan': |
|||
return Number(fieldValue) > Number(ruleValue); |
|||
case 'lessThan': |
|||
return Number(fieldValue) < Number(ruleValue); |
|||
default: |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
private applyConditionalAction(fieldKey: string, action: string, shouldApply: boolean) { |
|||
const control = this.dynamicForm.get(fieldKey); |
|||
|
|||
switch (action) { |
|||
case ConditionalAction.SHOW: |
|||
this.fieldVisibility = { ...this.fieldVisibility, [fieldKey]: shouldApply }; |
|||
break; |
|||
case ConditionalAction.HIDE: |
|||
this.fieldVisibility = { ...this.fieldVisibility, [fieldKey]: !shouldApply }; |
|||
break; |
|||
case ConditionalAction.ENABLE: |
|||
if (control) { |
|||
shouldApply ? control.enable() : control.disable(); |
|||
} |
|||
break; |
|||
case ConditionalAction.DISABLE: |
|||
if (control) { |
|||
shouldApply ? control.disable() : control.enable(); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
private setupFormAndLogic() { |
|||
this.dynamicForm = this.dynamicFormService.createFormGroup(this.fields()); |
|||
this.initializeFieldVisibility(); |
|||
this.setupConditionalLogic(); |
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
|
|||
private markAllFieldsAsTouched() { |
|||
Object.keys(this.dynamicForm.controls).forEach(key => { |
|||
this.dynamicForm.get(key)?.markAsTouched(); |
|||
}); |
|||
} |
|||
|
|||
private focusFirstInvalidField() { |
|||
// Accessibility: Focus first invalid field for screen readers
|
|||
const firstInvalidField = this.sortedFields.find(field => { |
|||
const control = this.dynamicForm.get(field.key); |
|||
return control && control.invalid && control.touched; |
|||
}); |
|||
|
|||
if (firstInvalidField) { |
|||
setTimeout(() => { |
|||
const element = document.getElementById(`field-${firstInvalidField.key}`); |
|||
if (element) { |
|||
element.focus(); |
|||
element.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
|||
} |
|||
}, 100); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,60 @@ |
|||
import { Type } from '@angular/core'; |
|||
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' | 'group' | 'array'; |
|||
label: string; |
|||
placeholder?: string; |
|||
required?: boolean; |
|||
disabled?: boolean; |
|||
options?: OptionProps<T>; |
|||
validators?: ValidatorConfig[]; |
|||
conditionalLogic?: ConditionalRule[]; |
|||
order?: number; |
|||
gridSize?: number; |
|||
component?: Type<ControlValueAccessor>; |
|||
// Additional field attributes
|
|||
min?: number | string; // For number, date, time, range
|
|||
max?: number | string; // For number, date, time, range
|
|||
step?: number | string; // For number, time, range
|
|||
minLength?: number; // For text, password
|
|||
maxLength?: number; // For text, password
|
|||
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 { |
|||
type: 'required' | 'email' | 'minLength' | 'maxLength' | 'pattern' | 'custom' | 'min' | 'max' | 'requiredTrue'; |
|||
value?: any; |
|||
message: string; |
|||
} |
|||
|
|||
export interface ConditionalRule { |
|||
dependsOn: string; |
|||
condition: 'equals' | 'notEquals' | 'contains' | 'greaterThan' | 'lessThan'; |
|||
value: any; |
|||
action: 'show' | 'hide' | 'enable' | 'disable'; |
|||
} |
|||
|
|||
export enum ConditionalAction { |
|||
SHOW = 'show', |
|||
HIDE = 'hide', |
|||
ENABLE = 'enable', |
|||
DISABLE = 'disable' |
|||
} |
|||
|
|||
export interface OptionProps<T = any> { |
|||
defaultValues?: T[]; |
|||
url?: string; |
|||
disabled?: (option: T) => boolean; |
|||
labelProp?: string; |
|||
valueProp?: string; |
|||
apiName?: string; |
|||
} |
|||
@ -0,0 +1,119 @@ |
|||
import {Injectable, inject} from '@angular/core'; |
|||
import {FormControl, FormGroup, FormArray, ValidatorFn, Validators, FormBuilder} from '@angular/forms'; |
|||
import {FormFieldConfig, ValidatorConfig} from './dynamic-form.models'; |
|||
import { RestService } from '@abp/ng.core'; |
|||
|
|||
@Injectable({ |
|||
providedIn: 'root' |
|||
}) |
|||
|
|||
export class DynamicFormService { |
|||
|
|||
private formBuilder = inject(FormBuilder); |
|||
private restService = inject(RestService); |
|||
apiName = 'DynamicFormService'; |
|||
|
|||
createFormGroup(fields: FormFieldConfig[]): FormGroup { |
|||
const group: any = {}; |
|||
|
|||
fields.forEach(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); |
|||
} |
|||
}); |
|||
|
|||
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 => { |
|||
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; |
|||
} |
|||
|
|||
getOptions(url: string, apiName?: string): any { |
|||
return this.restService.request<any, any[]>({ |
|||
method: 'GET', |
|||
url, |
|||
}, |
|||
{ apiName: apiName || this.apiName }); |
|||
} |
|||
|
|||
private buildValidators(validatorConfigs: ValidatorConfig[]): ValidatorFn[] { |
|||
return validatorConfigs.map(config => { |
|||
switch (config.type) { |
|||
case 'required': |
|||
return Validators.required; |
|||
case 'email': |
|||
return Validators.email; |
|||
case 'minLength': |
|||
return Validators.minLength(config.value); |
|||
case 'maxLength': |
|||
return Validators.maxLength(config.value); |
|||
case 'pattern': |
|||
return Validators.pattern(config.value); |
|||
case 'min': |
|||
return Validators.min(config.value); |
|||
case 'max': |
|||
return Validators.max(config.value); |
|||
case 'requiredTrue': |
|||
return Validators.requiredTrue; |
|||
default: |
|||
return Validators.nullValidator; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private getInitialValue(field: FormFieldConfig): any { |
|||
if (field.value !== undefined) { |
|||
return field.value; |
|||
} |
|||
switch (field.type) { |
|||
case 'checkbox': |
|||
return false; |
|||
case 'number': |
|||
return 0; |
|||
case 'group': |
|||
return this.getInitialValues(field.children || []); |
|||
case 'array': |
|||
return []; |
|||
default: |
|||
return ''; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +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