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