diff --git a/npm/ng-packs/apps/dev-app/src/app/app.routes.ts b/npm/ng-packs/apps/dev-app/src/app/app.routes.ts index 47462b7f8fa..c520328975b 100644 --- a/npm/ng-packs/apps/dev-app/src/app/app.routes.ts +++ b/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()), diff --git a/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html b/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html new file mode 100644 index 00000000000..c675dca707f --- /dev/null +++ b/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html @@ -0,0 +1,111 @@ +
+
+
+

Dynamic Form Showcase

+

+ Comprehensive example demonstrating all available field types with validation and conditional logic. +

+
+
+
+ +
+
+
+
+

+ + User Registration Form +

+ All 16 field types + Nested Forms (Group & Array) with full accessibility support +
+
+ @if (formFields.length) { + + } @else { +
+
+ Loading... +
+

Loading form configuration...

+
+ } +
+
+ + +
+
+
+ + Available Field Types +
+
+
+
+
+
Text Inputs
+
    +
  • Text
  • +
  • Email
  • +
  • Password
  • +
  • Tel
  • +
  • URL
  • +
  • Textarea
  • +
+
+
+
Special Inputs
+
    +
  • Number
  • +
  • Date
  • +
  • DateTime-Local
  • +
  • Time
  • +
  • Range
  • +
  • Color
  • +
+
+
+
Selection
+
    +
  • Select (Dropdown)
  • +
  • Radio
  • +
  • Checkbox
  • +
+
+
+
Files
+
    +
  • File (Single/Multiple)
  • +
+
+
+
Nested Forms
+
    +
  • Group (Nested Fields)
  • +
  • Array (Dynamic List)
  • +
+
+
+
+
+ + Features: Full validation support, conditional logic, nested forms (groups & arrays), + grid-based layout, ARIA accessibility, keyboard navigation, and screen reader support. +
+
+
+ + New: Try the nested forms below! Add/remove phone numbers dynamically, + or fill in your work experience. The Address section shows a grouped form. +
+
+
+
+
+
\ No newline at end of file diff --git a/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts b/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts new file mode 100644 index 00000000000..0955a448e5a --- /dev/null +++ b/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(); + } +} diff --git a/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/form-config.service.ts b/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/form-config.service.ts new file mode 100644 index 00000000000..b88423f945c --- /dev/null +++ b/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 { + 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); + } +} diff --git a/npm/ng-packs/apps/dev-app/src/app/home/home.component.html b/npm/ng-packs/apps/dev-app/src/app/home/home.component.html index 1a0d46c2daf..d83f2be7f16 100644 --- a/npm/ng-packs/apps/dev-app/src/app/home/home.component.html +++ b/npm/ng-packs/apps/dev-app/src/app/home/home.component.html @@ -1,4 +1,7 @@
+
+

{{ '::Welcome' | abpLocalization }}

{{ '::LongWelcomeMessage' | abpLocalization }}

@if (!hasLoggedIn) { - - {{ 'AbpAccount::Login' | abpLocalization }} - + + {{ 'AbpAccount::Login' | abpLocalization }} + }
@@ -30,8 +28,7 @@

Let's improve your application!

- Let's improve your application! ] } } - " - > + "> - Let's improve your application! ] } } - " - > + "> - Let's improve your application! ] } } - " - > + ">
- Let's improve your application! ] } } - " - > + ">

- - + + "> - + style="width: 72px; height: 28px; border: none; display: inline-block">

- Let's improve your application! customTemplate: githubButtonsTemplate } } - " - > + "> - Let's improve your application! ] } } - " - > + ">
@@ -199,8 +179,7 @@

Meet the ABP

- Meet the ABP href: 'https://abp.io/startup-templates?ref=tmpl' } } - " - > + "> - Meet the ABP href: 'https://abp.io/modules?ref=tmpl' } } - " - > + "> - Meet the ABP href: 'https://abp.io/tools?ref=tmpl' } } - " - > + "> - Meet the ABP href: 'https://abp.io/themes?ref=tmpl' } } - " - > + "> - Meet the ABP href: 'https://abp.io/support/questions?ref=tmpl' } } - " - > + "> - Meet the ABP href: 'https://abp.io/services' } } - " - > + ">
@@ -301,12 +263,11 @@

@if (context.customTemplate) { - + } @for (link of context.links; track $index) { - {{ link.label }} + {{ link.label }} }
@@ -318,9 +279,8 @@
- Details + Details
@@ -340,4 +300,4 @@
border-left: 0 !important; } } - + \ No newline at end of file diff --git a/npm/ng-packs/apps/dev-app/src/app/home/home.component.ts b/npm/ng-packs/apps/dev-app/src/app/home/home.component.ts index 44bf9f269ec..aa25b046d7c 100644 --- a/npm/ng-packs/apps/dev-app/src/app/home/home.component.ts +++ b/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; } diff --git a/npm/ng-packs/apps/dev-app/src/assets/form-config.json b/npm/ng-packs/apps/dev-app/src/assets/form-config.json new file mode 100644 index 00000000000..d6876b9e969 --- /dev/null +++ b/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 + } +] diff --git a/npm/ng-packs/apps/dev-app/src/server.ts b/npm/ng-packs/apps/dev-app/src/server.ts index a8d7558341c..fb45c1dec0a 100644 --- a/npm/ng-packs/apps/dev-app/src/server.ts +++ b/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'); diff --git a/npm/ng-packs/packages/components/dynamic-form/NESTED-FORMS.md b/npm/ng-packs/packages/components/dynamic-form/NESTED-FORMS.md new file mode 100644 index 00000000000..1e628035bb0 --- /dev/null +++ b/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** (`
`, ``) +- ✅ **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 + diff --git a/npm/ng-packs/packages/components/dynamic-form/ng-package.json b/npm/ng-packs/packages/components/dynamic-form/ng-package.json new file mode 100644 index 00000000000..e09fb3fd037 --- /dev/null +++ b/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" + } +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.html b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.html new file mode 100644 index 00000000000..2608180d15c --- /dev/null +++ b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.html @@ -0,0 +1,93 @@ +@if (visible()) { +
+ + +
+ + +
+ + +
+ @for (item of formArray.controls; track trackByIndex($index)) { +
+ + +
+ + {{ arrayConfig().label | abpLocalization }} #{{ $index + 1 }} + + +
+ + +
+ @for (field of sortedChildren; track field.key) { +
+ + + @if (field.type === 'group') { + + } + + + @else if (field.type === 'array') { + + } + + + @else { + + } + +
+ } +
+
+ } @empty { +
+ + {{ '::NoItemsAdded' | abpLocalization }} +
+ } +
+ + + +
+} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.scss b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.scss new file mode 100644 index 00000000000..71707783e83 --- /dev/null +++ b/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; +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.ts new file mode 100644 index 00000000000..eb86a9da7f6 --- /dev/null +++ b/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(); + formGroup = input.required(); + visible = input(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; + } +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/index.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/index.ts new file mode 100644 index 00000000000..2ea9bc1460d --- /dev/null +++ b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/index.ts @@ -0,0 +1 @@ +export * from './dynamic-form-array.component'; diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts new file mode 100644 index 00000000000..56f32f6e0d1 --- /dev/null +++ b/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 & { setDisabledState?(d: boolean): void }; +type acceptsFormControl = { formControl?: FormControl }; + +@Component({ + selector: 'abp-dynamic-form-field-host', + imports: [CommonModule, ReactiveFormsModule], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DynamicFieldHostComponent), + multi: true + }] +}) +export class DynamicFieldHostComponent implements ControlValueAccessor { + component = input>(); + inputs = input>({}); + + @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(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 }); + } + } +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html new file mode 100644 index 00000000000..ed33dca2275 --- /dev/null +++ b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html @@ -0,0 +1,371 @@ +@if (visible()) { +
+ + + + + @if (field().type === 'text') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'select') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'checkbox') { + +
+ + @if (isInvalid) { + + } +
+ } @else if (field().type === 'email') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'textarea') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'number') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'date') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'datetime-local') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'time') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'password') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'tel') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'url') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'radio') { + +
+ +
+ @for (option of options$ | async; track option.key) { +
+ + +
+ } +
+ @if (isInvalid) { + + } +
+ } @else if (field().type === 'file') { + +
+ + + @if (isInvalid) { + + } +
+ } @else if (field().type === 'range') { + +
+ +
+ + {{ value.value }} +
+ @if (isInvalid) { + + } +
+ } @else if (field().type === 'color') { + +
+ +
+ + {{ value.value || '#000000' }} +
+ @if (isInvalid) { + + } +
+ } +
+} + + + + + + + + \ No newline at end of file diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.scss b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.scss new file mode 100644 index 00000000000..12870a41411 --- /dev/null +++ b/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; + } +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.ts new file mode 100644 index 00000000000..5302d5cdb9e --- /dev/null +++ b/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('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(); + visible = input(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 = () => { }; +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/index.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/index.ts new file mode 100644 index 00000000000..826f7c70d20 --- /dev/null +++ b/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'; diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.html b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.html new file mode 100644 index 00000000000..1222faa58ab --- /dev/null +++ b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.html @@ -0,0 +1,36 @@ +@if (visible()) { +
+ + {{ groupConfig().label | abpLocalization }} + + +
+ @for (field of sortedChildren; track field.key) { +
+ + + @if (field.type === 'group') { + + } + + + @else if (field.type === 'array') { + + } + + + @else { + + } + +
+ } +
+
+} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.scss b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.scss new file mode 100644 index 00000000000..b98d92458cc --- /dev/null +++ b/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; +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/dynamic-form-group.component.ts new file mode 100644 index 00000000000..ab9cb0f3adf --- /dev/null +++ b/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(); + formGroup = input.required(); + visible = input(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); + } +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/index.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/index.ts new file mode 100644 index 00000000000..899c3e295e0 --- /dev/null +++ b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-group/index.ts @@ -0,0 +1 @@ +export * from './dynamic-form-group.component'; diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.html b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.html new file mode 100644 index 00000000000..17bce17b1f7 --- /dev/null +++ b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.html @@ -0,0 +1,78 @@ +
+
+
+ @for (field of sortedFields; track field.key) { +
+ + + @if (field.component) { + + + } + + + @else if (field.type === 'group') { + + + } + + + @else if (field.type === 'array') { + + + } + + + @else { + + + } + +
+ } +
+ + + + +
+ + +
+ @if (showCancelButton()) { + + } + +
+
+
diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.scss b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.scss new file mode 100644 index 00000000000..038d8eed943 --- /dev/null +++ b/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; +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts new file mode 100644 index 00000000000..9e03cc0a2eb --- /dev/null +++ b/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([]); + values = input>(); + submitButtonText = input('Submit'); + submitInProgress = input(false); + showCancelButton = input(false); + onSubmit = output(); + formCancel = output(); + 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); + } + } +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.models.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.models.ts new file mode 100644 index 00000000000..864fc989f5c --- /dev/null +++ b/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 { + 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; + validators?: ValidatorConfig[]; + conditionalLogic?: ConditionalRule[]; + order?: number; + gridSize?: number; + component?: Type; + // 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 { + defaultValues?: T[]; + url?: string; + disabled?: (option: T) => boolean; + labelProp?: string; + valueProp?: string; + apiName?: string; +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.service.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.service.ts new file mode 100644 index 00000000000..0dbc302c20a --- /dev/null +++ b/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({ + 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 ''; + } + } +} diff --git a/npm/ng-packs/packages/components/dynamic-form/src/public-api.ts b/npm/ng-packs/packages/components/dynamic-form/src/public-api.ts new file mode 100644 index 00000000000..f9dc6707376 --- /dev/null +++ b/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'; diff --git a/npm/ng-packs/tsconfig.base.json b/npm/ng-packs/tsconfig.base.json index 496d83ca676..5e84e8105ba 100644 --- a/npm/ng-packs/tsconfig.base.json +++ b/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"], diff --git a/templates/app/angular/src/app/home/home.component.html b/templates/app/angular/src/app/home/home.component.html index e3e73e48ae6..edcdc66bc06 100644 --- a/templates/app/angular/src/app/home/home.component.html +++ b/templates/app/angular/src/app/home/home.component.html @@ -1,4 +1,5 @@
+
diff --git a/templates/app/angular/src/app/home/home.component.ts b/templates/app/angular/src/app/home/home.component.ts index 420edd47247..ee6bd838c91 100644 --- a/templates/app/angular/src/app/home/home.component.ts +++ b/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; }