Browse Source

Merge pull request #24701 from abpframework/auto-merge/rel-10-1/4299

Merge branch dev with rel-10.1
pull/24712/head
Ma Liming 2 weeks ago
committed by GitHub
parent
commit
d79641e7f7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      npm/ng-packs/apps/dev-app/src/app/app.routes.ts
  2. 111
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html
  3. 38
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts
  4. 381
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/form-config.service.ts
  5. 140
      npm/ng-packs/apps/dev-app/src/app/home/home.component.html
  6. 12
      npm/ng-packs/apps/dev-app/src/app/home/home.component.ts
  7. 73
      npm/ng-packs/apps/dev-app/src/assets/form-config.json
  8. 4
      npm/ng-packs/apps/dev-app/src/server.ts
  9. 338
      npm/ng-packs/packages/components/dynamic-form/NESTED-FORMS.md
  10. 6
      npm/ng-packs/packages/components/dynamic-form/ng-package.json
  11. 93
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.html
  12. 75
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.scss
  13. 88
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.ts
  14. 1
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/index.ts
  15. 135
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts
  16. 371
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html
  17. 12
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.scss
  18. 180
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.ts
  19. 2
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/index.ts
  20. 36
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.html
  21. 26
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.scss
  22. 45
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.ts
  23. 1
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/index.ts
  24. 78
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.html
  25. 15
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.scss
  26. 203
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts
  27. 60
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.models.ts
  28. 119
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.service.ts
  29. 6
      npm/ng-packs/packages/components/dynamic-form/src/public-api.ts
  30. 1
      npm/ng-packs/tsconfig.base.json
  31. 1
      templates/app/angular/src/app/home/home.component.html
  32. 75
      templates/app/angular/src/app/home/home.component.ts

4
npm/ng-packs/apps/dev-app/src/app/app.routes.ts

@ -6,6 +6,10 @@ export const appRoutes: Routes = [
pathMatch: 'full',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
},
{
path: 'dynamic-form',
loadComponent: () => import('./dynamic-form-page/dynamic-form-page.component').then(m => m.DynamicFormPageComponent),
},
{
path: 'account',
loadChildren: () => import('@abp/ng.account').then(m => m.createRoutes()),

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

@ -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>

38
npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts

@ -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();
}
}

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

@ -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);
}
}

140
npm/ng-packs/apps/dev-app/src/app/home/home.component.html

@ -1,4 +1,7 @@
<div class="container">
<div class="text-center mb-4">
<a routerLink="/dynamic-form" class="btn btn-primary">Go to Dynamic Form</a>
</div>
<div class="p-5 text-center">
<div class="d-inline-block bg-success text-white p-1 h5 rounded mb-4" role="alert">
<h5 class="m-1">
@ -6,21 +9,16 @@
<strong>MyProjectName</strong> is successfully running!
</h5>
</div>
<h1>{{ '::Welcome' | abpLocalization }}</h1>
<p class="lead px-lg-5 mx-lg-5">{{ '::LongWelcomeMessage' | abpLocalization }}</p>
@if (!hasLoggedIn) {
<abp-button
[loading]="loading"
(click)="login()"
[disabled]="loading"
class="px-4 ml-1"
role="button"
iconClass="fa fa-sign-in"
>
{{ 'AbpAccount::Login' | abpLocalization }}
</abp-button>
<abp-button [loading]="loading" (click)="login()" [disabled]="loading" class="px-4 ml-1" role="button"
iconClass="fa fa-sign-in">
{{ 'AbpAccount::Login' | abpLocalization }}
</abp-button>
}
</div>
<div class="my-3 text-center">
@ -30,8 +28,7 @@
<abp-card cardClass="mt-4 mb-5">
<abp-card-body>
<div class="row text-center justify-content-md-center">
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
starterLinkTemplate;
context: {
$implicit: {
@ -46,11 +43,9 @@
]
}
}
"
></ng-container>
"></ng-container>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
starterLinkTemplate;
context: {
$implicit: {
@ -64,11 +59,9 @@
]
}
}
"
></ng-container>
"></ng-container>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
starterLinkTemplate;
context: {
$implicit: {
@ -86,12 +79,10 @@
]
}
}
"
></ng-container>
"></ng-container>
</div>
<div class="row text-center mt-lg-3 justify-content-md-center">
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
starterLinkTemplate;
context: {
$implicit: {
@ -105,13 +96,11 @@
]
}
}
"
></ng-container>
"></ng-container>
<ng-template #githubButtonsTemplate>
<p class="mb-1">
<iframe
scrolling="no"
<iframe scrolling="no"
src="https://buttons.github.io/buttons.html#href=https%3A%2F%2Fgithub.com%2Fabpframework%2Fabp&amp;title=&amp;aria-label=Star%20tabalinas%2Fjsgrid%20on%20GitHub&amp;data-icon=octicon-star&amp;data-text=Star&amp;data-size=large&amp;data-show-count=true"
style="
width: 122px;
@ -119,10 +108,8 @@
border: none;
display: inline-block;
margin-right: 4px;
"
></iframe>
<iframe
scrolling="no"
"></iframe>
<iframe scrolling="no"
src="https://buttons.github.io/buttons.html#href=https%3A%2F%2Fgithub.com%2Fabpframework%2Fabp%2Fissues&amp;title=&amp;aria-label=Issue%20tabalinas%2Fjsgrid%20on%20GitHub&amp;data-icon=octicon-issue-opened&amp;data-text=Issue&amp;data-size=large"
style="
width: 72px;
@ -130,19 +117,15 @@
border: none;
display: inline-block;
margin-right: 4px;
"
></iframe>
"></iframe>
<iframe
scrolling="no"
<iframe scrolling="no"
src="https://buttons.github.io/buttons.html#href=https%3A%2F%2Fgithub.com%2Fabpframework%2Fabp%2Ffork&amp;title=&amp;aria-label=Fork%20tabalinas%2Fjsgrid%20on%20GitHub&amp;data-icon=octicon-repo-forked&amp;data-text=Fork&amp;data-size=large&amp;"
style="width: 72px; height: 28px; border: none; display: inline-block"
></iframe>
style="width: 72px; height: 28px; border: none; display: inline-block"></iframe>
</p>
</ng-template>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
starterLinkTemplate;
context: {
$implicit: {
@ -158,11 +141,9 @@
customTemplate: githubButtonsTemplate
}
}
"
></ng-container>
"></ng-container>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
starterLinkTemplate;
context: {
$implicit: {
@ -180,8 +161,7 @@
]
}
}
"
></ng-container>
"></ng-container>
</div>
</abp-card-body>
</abp-card>
@ -199,8 +179,7 @@
</p>
<div class="row text-center justify-content-md-center">
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
featuresTemplate;
context: {
$implicit: {
@ -208,11 +187,9 @@
href: 'https://abp.io/startup-templates?ref=tmpl'
}
}
"
></ng-container>
"></ng-container>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
featuresTemplate;
context: {
$implicit: {
@ -220,11 +197,9 @@
href: 'https://abp.io/modules?ref=tmpl'
}
}
"
></ng-container>
"></ng-container>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
featuresTemplate;
context: {
$implicit: {
@ -232,11 +207,9 @@
href: 'https://abp.io/tools?ref=tmpl'
}
}
"
></ng-container>
"></ng-container>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
featuresTemplate;
context: {
$implicit: {
@ -244,11 +217,9 @@
href: 'https://abp.io/themes?ref=tmpl'
}
}
"
></ng-container>
"></ng-container>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
featuresTemplate;
context: {
$implicit: {
@ -256,11 +227,9 @@
href: 'https://abp.io/support/questions?ref=tmpl'
}
}
"
></ng-container>
"></ng-container>
<ng-container
*ngTemplateOutlet="
<ng-container *ngTemplateOutlet="
featuresTemplate;
context: {
$implicit: {
@ -268,26 +237,19 @@
href: 'https://abp.io/services'
}
}
"
></ng-container>
"></ng-container>
</div>
</abp-card-body>
</abp-card>
<div class="mb-5 text-center">
<p class="align-middle">
<a href="https://twitter.com/abpframework" target="_blank" class="mx-2"
><i class="fa fa-twitter" aria-hidden="true"></i
><span class="text-secondary"> Abp Framework</span></a
>
<a href="https://twitter.com/abpcommercial" target="_blank" class="mx-2"
><i class="fa fa-twitter" aria-hidden="true"></i
><span class="text-secondary"> Abp </span></a
>
<a href="https://github.com/abpframework/abp" target="_blank" class="mx-2"
><i class="fa fa-github" aria-hidden="true"></i
><span class="text-secondary"> abpframework</span></a
>
<a href="https://twitter.com/abpframework" target="_blank" class="mx-2"><i class="fa fa-twitter"
aria-hidden="true"></i><span class="text-secondary"> Abp Framework</span></a>
<a href="https://twitter.com/abpcommercial" target="_blank" class="mx-2"><i class="fa fa-twitter"
aria-hidden="true"></i><span class="text-secondary"> Abp </span></a>
<a href="https://github.com/abpframework/abp" target="_blank" class="mx-2"><i class="fa fa-github"
aria-hidden="true"></i><span class="text-secondary"> abpframework</span></a>
</p>
</div>
</div>
@ -301,12 +263,11 @@
</h5>
<p [innerHTML]="context.description"></p>
@if (context.customTemplate) {
<ng-container [ngTemplateOutlet]="context.customTemplate"></ng-container>
<ng-container [ngTemplateOutlet]="context.customTemplate"></ng-container>
}
@for (link of context.links; track $index) {
<a [href]="link.href" target="_blank" class="btn btn-link px-1"
>{{ link.label }} <i class="fas fa-chevron-right" aria-hidden="true"></i
></a>
<a [href]="link.href" target="_blank" class="btn btn-link px-1">{{ link.label }} <i class="fas fa-chevron-right"
aria-hidden="true"></i></a>
}
</div>
</div>
@ -318,9 +279,8 @@
<h6>
<i class="fas fa-plus d-block mb-3 fa- 2x text-secondary" aria-hidden="true"></i>
<span [innerHTML]="context.title"></span>
<a [href]="context.href" target="_blank" class="d-block mt-2 btn btn-sm btn-link"
>Details <i class="fas fa-chevron-right" aria-hidden="true"></i
></a>
<a [href]="context.href" target="_blank" class="d-block mt-2 btn btn-sm btn-link">Details <i
class="fas fa-chevron-right" aria-hidden="true"></i></a>
</h6>
</div>
</div>
@ -340,4 +300,4 @@
border-left: 0 !important;
}
}
</style>
</style>

12
npm/ng-packs/apps/dev-app/src/app/home/home.component.ts

@ -2,16 +2,24 @@ import { AuthService, LocalizationPipe } from '@abp/ng.core';
import { Component, inject } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent, CardBodyComponent, CardComponent } from '@abp/ng.theme.shared';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
imports: [NgTemplateOutlet, LocalizationPipe, CardComponent, CardBodyComponent, ButtonComponent],
imports: [
NgTemplateOutlet,
LocalizationPipe,
CardComponent,
CardBodyComponent,
ButtonComponent,
RouterLink
],
})
export class HomeComponent {
protected readonly authService = inject(AuthService);
loading = false;
get hasLoggedIn(): boolean {
return this.authService.isAuthenticated;
}

73
npm/ng-packs/apps/dev-app/src/assets/form-config.json

@ -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
}
]

4
npm/ng-packs/apps/dev-app/src/server.ts

@ -11,9 +11,7 @@ import {environment} from './environments/environment';
import * as oidc from 'openid-client';
import { ServerCookieParser } from '@abp/ng.core';
if (environment.production === false) {
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
}
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');

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

@ -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

6
npm/ng-packs/packages/components/dynamic-form/ng-package.json

@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}

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

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

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

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

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

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

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

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

135
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts

@ -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 });
}
}
}

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

@ -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>

12
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.scss

@ -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;
}
}

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

@ -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 = () => { };
}

2
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/index.ts

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

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

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

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

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

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

@ -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);
}
}

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

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

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

@ -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>

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

@ -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;
}

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

@ -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);
}
}
}

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

@ -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;
}

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

@ -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 '';
}
}
}

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

@ -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';

1
npm/ng-packs/tsconfig.base.json

@ -21,6 +21,7 @@
"@abp/ng.account/config": ["packages/account/config/src/public-api.ts"],
"@abp/ng.components": ["packages/components/src/public-api.ts"],
"@abp/ng.components/chart.js": ["packages/components/chart.js/src/public-api.ts"],
"@abp/ng.components/dynamic-form": ["packages/components/dynamic-form/src/public-api.ts"],
"@abp/ng.components/extensible": ["packages/components/extensible/src/public-api.ts"],
"@abp/ng.components/lookup": ["packages/components/lookup/src/public-api.ts"],
"@abp/ng.components/page": ["packages/components/page/src/public-api.ts"],

1
templates/app/angular/src/app/home/home.component.html

@ -1,4 +1,5 @@
<div class="row mb-3">
<abp-dynamic-form [fields]="formFields" />
<div class="col-xl-6 col-12 d-flex">
<div class="card h-lg-100 w-100 overflow-hidden">
<div class="card-body">

75
templates/app/angular/src/app/home/home.component.ts

@ -1,16 +1,89 @@
import {AuthService, LocalizationPipe} from '@abp/ng.core';
import { Component, inject } from '@angular/core';
import {NgTemplateOutlet} from "@angular/common";
import {DynamicFormComponent, FormFieldConfig} from "@abp/ng.components/dynamic-form";
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
imports: [NgTemplateOutlet, LocalizationPipe]
imports: [NgTemplateOutlet, LocalizationPipe, DynamicFormComponent]
})
export class HomeComponent {
private authService = inject(AuthService);
formFields: FormFieldConfig[] = [
{
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: 'Email Address',
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
}
];
get hasLoggedIn(): boolean {
return this.authService.isAuthenticated;
}

Loading…
Cancel
Save