@ng-forge/dynamic-form-mcp 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/ng-forge-mcp.js +1782 -283
- package/package.json +7 -7
- package/src/index.js +1782 -283
- package/src/registry/api-reference.json +76 -25
- package/src/registry/docs.json +52 -16
- package/src/registry/field-types.json +18 -6
- package/src/registry/instructions.d.ts +1 -1
- package/src/registry/instructions.d.ts.map +1 -1
- package/src/registry/validators.json +56 -4
- package/src/tools/data/lookup-topics.d.ts.map +1 -1
- package/src/tools/examples.tool.d.ts.map +1 -1
- package/src/tools/lookup.tool.d.ts.map +1 -1
- package/src/tools/scaffold.tool.d.ts.map +1 -1
- package/src/tools/validate.tool.d.ts.map +1 -1
package/bin/ng-forge-mcp.js
CHANGED
|
@@ -18,13 +18,13 @@ var require_docs = __commonJS({
|
|
|
18
18
|
id: "advanced-events",
|
|
19
19
|
title: "Advanced Events",
|
|
20
20
|
category: "advanced",
|
|
21
|
-
content: "The event bus enables communication between the `dynamic-form` component and custom field components within it. Each form instance has its own isolated event bus.\n\n## Overview\n\nThe event bus provides:\n\n- Communication between field components within a single form\n- Type-safe event subscriptions\n- Reactive event handling with RxJS\n- Wizard page navigation coordination\n- Custom field component workflows\n\n**Note**: The event bus is scoped to each `dynamic-form` instance - events don't cross form boundaries.\n\n## Usage in Custom Field Components\n\nWhen building custom field types, inject the event bus to communicate with the parent form or other fields:\n\n```typescript\nimport { Component, inject } from '@angular/core';\nimport { EventBus } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-custom-submit-button',\n template: `<button (click)=\"submit()\">Submit Form</button>`,\n})\nexport class CustomSubmitButton {\n eventBus = inject(EventBus);\n\n submit() {\n // Dispatch submit event to the parent dynamic-forms\n this.eventBus.dispatch(SubmitEvent);\n }\n}\n```\n\n## Built-in Events\n\n### SubmitEvent\n\nFired when form is submitted.\n\n```typescript\neventBus.on<SubmitEvent>('submit').subscribe(() => {\n // Handle form submission\n console.log('Form submitted');\n});\n```\n\n### PageChangeEvent\n\nFired when navigating between wizard pages.\n\n```typescript\neventBus.on<PageChangeEvent>('page-change').subscribe((event) => {\n console.log(`Page ${event.currentPageIndex + 1} of ${event.totalPages}`);\n console.log(`Previous page: ${event.previousPageIndex}`);\n});\n```\n\n**Properties:**\n\n- `currentPageIndex: number` - Current page (0-based)\n- `totalPages: number` - Total number of pages\n- `previousPageIndex?: number` - Previous page index\n\n### NextPageEvent\n\nNavigate to next page in wizard.\n\n```typescript\neventBus.dispatch(NextPageEvent);\n```\n\n### PreviousPageEvent\n\nNavigate to previous page in wizard.\n\n```typescript\neventBus.dispatch(PreviousPageEvent);\n```\n\n### FormResetEvent\n\nReset form to default values.\n\n```typescript\n// Dispatch reset event\neventBus.dispatch(FormResetEvent);\n\n// Listen for reset\neventBus.on<FormResetEvent>('form-reset').subscribe(() => {\n console.log('Form was reset to defaults');\n});\n```\n\n### FormClearEvent\n\nClear all form values (empty state, not defaults).\n\n```typescript\n// Dispatch clear event\neventBus.dispatch(FormClearEvent);\n\n// Listen for clear\neventBus.on<FormClearEvent>('form-clear').subscribe(() => {\n console.log('Form was cleared');\n});\n```\n\n### AddArrayItemEvent\n\nAdd an item to an array field.\n\n```typescript\n// Add item using array's template\neventBus.dispatch(AddArrayItemEvent, 'contacts');\n\n// Add item with custom field template\nconst fieldTemplate = { key: 'email', type: 'input', value: '' };\neventBus.dispatch(AddArrayItemEvent, 'contacts', fieldTemplate);\n\n// Add item at specific index\neventBus.dispatch(AddArrayItemEvent, 'contacts', fieldTemplate, 2);\n```\n\n**Constructor parameters:**\n\n- `arrayKey: string` - Key of the array field\n- `field?: ArrayAllowedChildren` - Optional field template (uses array's template if omitted)\n- `index?: number` - Optional index to insert at (defaults to end)\n\n### RemoveArrayItemEvent\n\nRemove an item from an array field.\n\n```typescript\n// Remove item at index 1\neventBus.dispatch(RemoveArrayItemEvent, 'contacts', 1);\n\n// Remove last item\neventBus.dispatch(RemoveArrayItemEvent, 'contacts');\n```\n\n**Constructor parameters:**\n\n- `arrayKey: string` - Key of the array field\n- `index?: number` - Index to remove (defaults to last item)\n\n## Multiple Event Subscriptions\n\nSubscribe to multiple event types by passing an array of type strings:\n\n```typescript\neventBus.on<SubmitEvent | PageChangeEvent | NextPageEvent>(['submit', 'page-change', 'next-page']).subscribe((event) => {\n switch (event.type) {\n case 'submit':\n handleSubmit();\n break;\n case 'page-change':\n handlePageChange(event);\n break;\n case 'next-page':\n handleNextPage();\n break;\n }\n});\n```\n\n## Custom Events\n\nCreate custom events for your forms:\n\n```typescript\nimport { FormEvent } from '@ng-forge/dynamic-forms';\n\n// Simple event\nexport class SaveDraftEvent implements FormEvent {\n readonly type = 'save-draft' as const;\n}\n\n// Event with payload\nexport class ValidationErrorEvent implements FormEvent {\n readonly type = 'validation-error' as const;\n\n constructor(\n public readonly fieldKey: string,\n public readonly errorMessage: string,\n ) {}\n}\n```\n\nUsage:\n\n```typescript\n// Dispatch simple event\neventBus.dispatch(SaveDraftEvent);\n\n// Dispatch event with payload\neventBus.dispatch(ValidationErrorEvent, 'email', 'Invalid email format');\n\n// Subscribe to custom event\neventBus.on<SaveDraftEvent>('save-draft').subscribe(() => {\n saveDraft();\n});\n\neventBus.on<ValidationErrorEvent>('validation-error').subscribe((event) => {\n showError(event.fieldKey, event.errorMessage);\n});\n```\n\n## Practical Examples\n\n### Form Auto-save\n\n```typescript\n@Component({...})\nexport class AutoSaveFormComponent {\n eventBus = inject(EventBus);\n\n ngOnInit() {\n // Auto-save on page change\n this.eventBus.on<PageChangeEvent>('page-change').subscribe(() => {\n this.saveDraft();\n });\n }\n\n saveDraft() {\n // Save form data\n }\n}\n```\n\n### Multi-step Form Navigation\n\n```typescript\n@Component({\n template: `\n <button (click)=\"previous()\">Previous</button>\n <button (click)=\"next()\">Next</button>\n <button (click)=\"submit()\">Submit</button>\n `,\n})\nexport class WizardNavigationComponent {\n eventBus = inject(EventBus);\n\n previous() {\n this.eventBus.dispatch(PreviousPageEvent);\n }\n\n next() {\n this.eventBus.dispatch(NextPageEvent);\n }\n\n submit() {\n this.eventBus.dispatch(SubmitEvent);\n }\n}\n```\n\n### Progress Tracking\n\n```typescript\n@Component({...})\nexport class ProgressTrackerComponent {\n eventBus = inject(EventBus);\n progress = signal(0);\n\n ngOnInit() {\n this.eventBus.on<PageChangeEvent>('page-change').subscribe((event) => {\n const percentage = ((event.currentPageIndex + 1) / event.totalPages) * 100;\n this.progress.set(percentage);\n });\n }\n}\n```\n\n### Form Reset and Clear\n\n```typescript\n@Component({...})\nexport class FormActionsComponent {\n eventBus = inject(EventBus);\n\n resetForm() {\n // Reset form to default values\n this.eventBus.dispatch(FormResetEvent);\n }\n\n clearForm() {\n // Clear all form values\n this.eventBus.dispatch(FormClearEvent);\n }\n}\n```\n\n## Best Practices\n\n**Use built-in events:**\n\n- Leverage existing events for common scenarios\n- Consistent behavior across forms\n\n**Create custom events for domain logic:**\n\n- Keep event names descriptive\n- Include necessary payload data\n- Use readonly properties\n\n**Type safety:**\n\n```typescript\n// \u2713 Type-safe event subscription\nclass UserUpdatedEvent implements FormEvent {\n readonly type = 'user-updated' as const;\n constructor(public readonly userId: string) {}\n}\n\n// \u2713 Use generic parameter for type inference\neventBus.on<UserUpdatedEvent>('user-updated').subscribe((event) => {\n console.log(event.userId); // TypeScript knows event has userId\n});\n\n// \u2717 Avoid subscriptions without generic type parameter\neventBus.on('some-random-event'); // No type checking on event properties\n```\n\n## Attaching Form Values to Events\n\nBy default, events don't include the form's current value. You can opt-in to automatically attach the form value to all dispatched events using `withEventFormValue()`.\n\n### Global Opt-in\n\nEnable for all forms in your application:\n\n```typescript\nimport { provideDynamicForm, withEventFormValue } from '@ng-forge/dynamic-forms';\n\nprovideDynamicForm(...withMaterialFields(), withEventFormValue());\n```\n\n### Per-Form Control\n\nOverride the global setting for specific forms:\n\n```typescript\nconst config: FormConfig = {\n fields: [...],\n options: {\n // Enable for this form (even if globally disabled)\n emitFormValueOnEvents: true,\n // Or disable for this form (even if globally enabled)\n // emitFormValueOnEvents: false,\n }\n};\n```\n\n### Accessing the Form Value\n\nUse the `hasFormValue()` type guard to safely access the attached value:\n\n```typescript\nimport { hasFormValue } from '@ng-forge/dynamic-forms';\n\neventBus.on<SubmitEvent>('submit').subscribe((event) => {\n if (hasFormValue(event)) {\n // TypeScript knows event.formValue exists\n console.log('Form value at submission:', event.formValue);\n sendToApi(event.formValue);\n }\n});\n```\n\nThis is useful when you need the complete form state at the time an event occurred, such as:\n\n- Logging form values on submission\n- Auto-saving on page changes\n- Analytics tracking\n\n## Event Flow\n\n1. Component dispatches event via `eventBus.dispatch()`\n2. Event bus broadcasts to all subscribers\n3. Subscribers receive event and execute handlers\n4. Type filtering ensures only relevant handlers execute\n\n## Integration with Forms\n\nThe event bus is automatically provided by the `DynamicForm` component to all child field components. You don't need to manually provide it:\n\n```typescript\n// The DynamicForm component internally provides EventBus\n// All child field components can inject and use it\nimport { DynamicForm } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-my-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"config\"></form>`,\n})\nexport class MyFormComponent {\n // EventBus is available to all field components rendered by DynamicForm\n}\n```\n\nField components can inject and use the event bus:\n\n```typescript\nexport class CustomFieldComponent extends BaseFieldComponent {\n eventBus = inject(EventBus);\n\n onSubmit() {\n // Trigger form submission from custom component\n this.eventBus.dispatch(SubmitEvent);\n }\n}\n```"
|
|
21
|
+
content: "The event bus enables communication between the `dynamic-form` component and custom field components within it. Each form instance has its own isolated event bus.\n\n## Overview\n\nThe event bus provides:\n\n- Communication between field components within a single form\n- Type-safe event subscriptions\n- Reactive event handling with RxJS\n- Wizard page navigation coordination\n- Custom field component workflows\n\n**Note**: The event bus is scoped to each `dynamic-form` instance - events don't cross form boundaries.\n\n## Usage in Custom Field Components\n\nWhen building custom field types, inject the event bus to communicate with the parent form or other fields:\n\n```typescript\nimport { Component, inject } from '@angular/core';\nimport { EventBus } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-custom-submit-button',\n template: `<button (click)=\"submit()\">Submit Form</button>`,\n})\nexport class CustomSubmitButton {\n eventBus = inject(EventBus);\n\n submit() {\n // Dispatch submit event to the parent dynamic-forms\n this.eventBus.dispatch(SubmitEvent);\n }\n}\n```\n\n## Built-in Events\n\n### SubmitEvent\n\nFired when form is submitted.\n\n```typescript\neventBus.on<SubmitEvent>('submit').subscribe(() => {\n // Handle form submission\n console.log('Form submitted');\n});\n```\n\n### PageChangeEvent\n\nFired when navigating between wizard pages.\n\n```typescript\neventBus.on<PageChangeEvent>('page-change').subscribe((event) => {\n console.log(`Page ${event.currentPageIndex + 1} of ${event.totalPages}`);\n console.log(`Previous page: ${event.previousPageIndex}`);\n});\n```\n\n**Properties:**\n\n- `currentPageIndex: number` - Current page (0-based)\n- `totalPages: number` - Total number of pages\n- `previousPageIndex?: number` - Previous page index\n\n### NextPageEvent\n\nNavigate to next page in wizard.\n\n```typescript\neventBus.dispatch(NextPageEvent);\n```\n\n### PreviousPageEvent\n\nNavigate to previous page in wizard.\n\n```typescript\neventBus.dispatch(PreviousPageEvent);\n```\n\n### FormResetEvent\n\nReset form to default values.\n\n```typescript\n// Dispatch reset event\neventBus.dispatch(FormResetEvent);\n\n// Listen for reset\neventBus.on<FormResetEvent>('form-reset').subscribe(() => {\n console.log('Form was reset to defaults');\n});\n```\n\n### FormClearEvent\n\nClear all form values (empty state, not defaults).\n\n```typescript\n// Dispatch clear event\neventBus.dispatch(FormClearEvent);\n\n// Listen for clear\neventBus.on<FormClearEvent>('form-clear').subscribe(() => {\n console.log('Form was cleared');\n});\n```\n\n### Array Events\n\nThe `arrayEvent()` builder provides a fluent API for array field manipulation. Import it from the main package:\n\n```typescript\nimport { arrayEvent } from '@ng-forge/dynamic-forms';\n```\n\n#### Adding Items\n\n**Important:** A template is required for all add operations. The template defines the structure of the new item:\n\n- **Single FieldDef** \u2192 creates a **primitive item** (field value extracted directly)\n- **Array of FieldDefs** \u2192 creates an **object item** (fields merged into object)\n\n```typescript\n// Define templates\nconst tagTemplate = { key: 'tag', type: 'input', label: 'Tag' }; // Primitive item\nconst contactTemplate = [\n // Object item\n { key: 'name', type: 'input', label: 'Name' },\n { key: 'email', type: 'input', label: 'Email' },\n];\n\n// Append item at end (most common)\neventBus.dispatch(arrayEvent('tags').append(tagTemplate)); // Primitive: adds a string value\neventBus.dispatch(arrayEvent('contacts').append(contactTemplate)); // Object: adds { name, email }\n\n// Prepend item at beginning\neventBus.dispatch(arrayEvent('contacts').prepend(contactTemplate));\n\n// Insert at specific index\neventBus.dispatch(arrayEvent('contacts').insertAt(2, contactTemplate));\n```\n\n#### Removing Items\n\n```typescript\n// Remove last item (stack pop)\neventBus.dispatch(arrayEvent('contacts').pop());\n\n// Remove first item (queue shift)\neventBus.dispatch(arrayEvent('contacts').shift());\n\n// Remove item at specific index\neventBus.dispatch(arrayEvent('contacts').removeAt(2));\n```\n\n#### Internal Event Classes\n\nFor advanced use cases (extending events or type-checking), you can import the underlying event classes:\n\n```typescript\nimport {\n AppendArrayItemEvent,\n PrependArrayItemEvent,\n InsertArrayItemEvent,\n PopArrayItemEvent,\n ShiftArrayItemEvent,\n RemoveAtIndexEvent,\n} from '@ng-forge/dynamic-forms';\n\n// Example: extend for custom template\nexport class AddContactEvent extends AppendArrayItemEvent {\n constructor() {\n super('contacts', [\n { key: 'name', type: 'input', label: 'Name' },\n { key: 'phone', type: 'input', label: 'Phone' },\n ]);\n }\n}\n\n// Usage\neventBus.dispatch(new AddContactEvent());\n```\n\n**Event types:**\n\n| Event | Description |\n| ----------------------- | ------------------------------ |\n| `AppendArrayItemEvent` | Add item at end of array |\n| `PrependArrayItemEvent` | Add item at beginning of array |\n| `InsertArrayItemEvent` | Add item at specific index |\n| `PopArrayItemEvent` | Remove last item |\n| `ShiftArrayItemEvent` | Remove first item |\n| `RemoveAtIndexEvent` | Remove item at specific index |\n\n## Multiple Event Subscriptions\n\nSubscribe to multiple event types by passing an array of type strings:\n\n```typescript\neventBus.on<SubmitEvent | PageChangeEvent | NextPageEvent>(['submit', 'page-change', 'next-page']).subscribe((event) => {\n switch (event.type) {\n case 'submit':\n handleSubmit();\n break;\n case 'page-change':\n handlePageChange(event);\n break;\n case 'next-page':\n handleNextPage();\n break;\n }\n});\n```\n\n## Custom Events\n\nCreate custom events for your forms:\n\n```typescript\nimport { FormEvent } from '@ng-forge/dynamic-forms';\n\n// Simple event\nexport class SaveDraftEvent implements FormEvent {\n readonly type = 'save-draft' as const;\n}\n\n// Event with payload\nexport class ValidationErrorEvent implements FormEvent {\n readonly type = 'validation-error' as const;\n\n constructor(\n public readonly fieldKey: string,\n public readonly errorMessage: string,\n ) {}\n}\n```\n\nUsage:\n\n```typescript\n// Dispatch simple event\neventBus.dispatch(SaveDraftEvent);\n\n// Dispatch event with payload\neventBus.dispatch(ValidationErrorEvent, 'email', 'Invalid email format');\n\n// Subscribe to custom event\neventBus.on<SaveDraftEvent>('save-draft').subscribe(() => {\n saveDraft();\n});\n\neventBus.on<ValidationErrorEvent>('validation-error').subscribe((event) => {\n showError(event.fieldKey, event.errorMessage);\n});\n```\n\n## Practical Examples\n\n### Form Auto-save\n\n```typescript\n@Component({...})\nexport class AutoSaveFormComponent {\n eventBus = inject(EventBus);\n\n ngOnInit() {\n // Auto-save on page change\n this.eventBus.on<PageChangeEvent>('page-change').subscribe(() => {\n this.saveDraft();\n });\n }\n\n saveDraft() {\n // Save form data\n }\n}\n```\n\n### Multi-step Form Navigation\n\n```typescript\n@Component({\n template: `\n <button (click)=\"previous()\">Previous</button>\n <button (click)=\"next()\">Next</button>\n <button (click)=\"submit()\">Submit</button>\n `,\n})\nexport class WizardNavigationComponent {\n eventBus = inject(EventBus);\n\n previous() {\n this.eventBus.dispatch(PreviousPageEvent);\n }\n\n next() {\n this.eventBus.dispatch(NextPageEvent);\n }\n\n submit() {\n this.eventBus.dispatch(SubmitEvent);\n }\n}\n```\n\n### Progress Tracking\n\n```typescript\n@Component({...})\nexport class ProgressTrackerComponent {\n eventBus = inject(EventBus);\n progress = signal(0);\n\n ngOnInit() {\n this.eventBus.on<PageChangeEvent>('page-change').subscribe((event) => {\n const percentage = ((event.currentPageIndex + 1) / event.totalPages) * 100;\n this.progress.set(percentage);\n });\n }\n}\n```\n\n### Form Reset and Clear\n\n```typescript\n@Component({...})\nexport class FormActionsComponent {\n eventBus = inject(EventBus);\n\n resetForm() {\n // Reset form to default values\n this.eventBus.dispatch(FormResetEvent);\n }\n\n clearForm() {\n // Clear all form values\n this.eventBus.dispatch(FormClearEvent);\n }\n}\n```\n\n## Best Practices\n\n**Use built-in events:**\n\n- Leverage existing events for common scenarios\n- Consistent behavior across forms\n\n**Create custom events for domain logic:**\n\n- Keep event names descriptive\n- Include necessary payload data\n- Use readonly properties\n\n**Type safety:**\n\n```typescript\n// \u2713 Type-safe event subscription\nclass UserUpdatedEvent implements FormEvent {\n readonly type = 'user-updated' as const;\n constructor(public readonly userId: string) {}\n}\n\n// \u2713 Use generic parameter for type inference\neventBus.on<UserUpdatedEvent>('user-updated').subscribe((event) => {\n console.log(event.userId); // TypeScript knows event has userId\n});\n\n// \u2717 Avoid subscriptions without generic type parameter\neventBus.on('some-random-event'); // No type checking on event properties\n```\n\n## Attaching Form Values to Events\n\nBy default, events don't include the form's current value. You can opt-in to automatically attach the form value to all dispatched events using `withEventFormValue()`.\n\n### Global Opt-in\n\nEnable for all forms in your application:\n\n```typescript\nimport { provideDynamicForm, withEventFormValue } from '@ng-forge/dynamic-forms';\n\nprovideDynamicForm(...withMaterialFields(), withEventFormValue());\n```\n\n### Per-Form Control\n\nOverride the global setting for specific forms:\n\n```typescript\nconst config: FormConfig = {\n fields: [...],\n options: {\n // Enable for this form (even if globally disabled)\n emitFormValueOnEvents: true,\n // Or disable for this form (even if globally enabled)\n // emitFormValueOnEvents: false,\n }\n};\n```\n\n### Accessing the Form Value\n\nUse the `hasFormValue()` type guard to safely access the attached value:\n\n```typescript\nimport { hasFormValue } from '@ng-forge/dynamic-forms';\n\neventBus.on<SubmitEvent>('submit').subscribe((event) => {\n if (hasFormValue(event)) {\n // TypeScript knows event.formValue exists\n console.log('Form value at submission:', event.formValue);\n sendToApi(event.formValue);\n }\n});\n```\n\nThis is useful when you need the complete form state at the time an event occurred, such as:\n\n- Logging form values on submission\n- Auto-saving on page changes\n- Analytics tracking\n\n## Event Flow\n\n1. Component dispatches event via `eventBus.dispatch()`\n2. Event bus broadcasts to all subscribers\n3. Subscribers receive event and execute handlers\n4. Type filtering ensures only relevant handlers execute\n\n## Integration with Forms\n\nThe event bus is automatically provided by the `DynamicForm` component to all child field components. You don't need to manually provide it:\n\n```typescript\n// The DynamicForm component internally provides EventBus\n// All child field components can inject and use it\nimport { DynamicForm } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-my-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"config\"></form>`,\n})\nexport class MyFormComponent {\n // EventBus is available to all field components rendered by DynamicForm\n}\n```\n\nField components can inject and use the event bus:\n\n```typescript\nexport class CustomFieldComponent extends BaseFieldComponent {\n eventBus = inject(EventBus);\n\n onSubmit() {\n // Trigger form submission from custom component\n this.eventBus.dispatch(SubmitEvent);\n }\n}\n```"
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
id: "advanced-expression-parser",
|
|
25
25
|
title: "Advanced Expression Parser",
|
|
26
26
|
category: "advanced",
|
|
27
|
-
content: "Dynamic forms use expressions for conditional logic and dynamic values. When you use JavaScript expressions with `type: 'javascript'`, the expression parser evaluates them safely, preventing code injection attacks while maintaining the flexibility you need.\n\n## How JavaScript Expressions Work\n\nWhen you configure JavaScript expressions in your form:\n\n```typescript\n{\n key: 'driverLicense',\n type: 'input',\n value: '',\n label: 'Driver License',\n logic: [\n {\n type: 'required',\n condition: {\n type: 'javascript',\n expression: 'formValue.age >= 18 && formValue.needsTransport === true'\n },\n }\n ]\n}\n```\n\nThe expression `'formValue.age >= 18 && formValue.needsTransport === true'` is evaluated by the parser, which has access to:\n\n```typescript\n{\n fieldValue: currentFieldValue,\n formValue: { age: 25, needsTransport: true, ... },\n fieldPath: 'driverLicense'\n}\n```\n\n## Security Model\n\nThe parser uses different rules for methods and properties:\n\n### Methods: Whitelist Only\n\nOnly safe methods can be called in expressions:\n\n```typescript\n// \u2705 Works - safe string methods\n{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.email.includes(\"@company.com\")'\n }\n}\n\n// \u2705 Works - safe array methods\n{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.roles.some((role) => role === \"admin\")'\n }\n}\n\n// \u274C Blocked - not whitelisted\n{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.text.link(\"url\")' // Error: Method \"link\" is not allowed\n }\n}\n```\n\n### Whitelisted Methods Reference\n\n| Category | Methods |\n| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **String** | `toUpperCase`, `toLowerCase`, `trim`, `includes`, `startsWith`, `endsWith`, `slice`, `substring`, `charAt`, `indexOf`, `lastIndexOf`, `split`, `replace`, `match` |\n| **Array** | `map`, `filter`, `some`, `every`, `find`, `findIndex`, `includes`, `indexOf`, `join`, `slice`, `flat`, `flatMap` |\n| **Number** | `toFixed`, `toString`, `valueOf` |\n| **Object** | `hasOwnProperty`, `toString`, `valueOf` |\n\n### Properties: Open Access (Except Dangerous Ones)\n\nAll properties in the form data are accessible, except prototype properties:\n\n```typescript\n// \u2705 Works - access any form field\nexpression: 'formValue.firstName';\nexpression: 'formValue._internalFlag';\nexpression: 'formValue.nested.deeply.property';\n\n// \u274C Blocked - prototype pollution risks\nexpression: 'formValue.constructor'; // Error: Property \"constructor\" is not accessible\nexpression: 'formValue.__proto__'; // Error: Property \"__proto__\" is not accessible\n```\n\n**Why?** Dynamic forms need to access any field name you define. The parser blocks only the dangerous properties that could break security.\n\n### Safe Member Access (Built-in)\n\n**Important**: Member access is safe by default - accessing properties on `null` or `undefined` returns `undefined` instead of throwing errors:\n\n```typescript\n// \u2705 All of these work safely, even when intermediate values are null/undefined\nexpression: 'formValue.user.profile.firstName';\nexpression: 'formValue.address.city.toLowerCase()';\nexpression: 'formValue.settings.notifications.email';\n\n// If formValue.user is null, the expression returns undefined (no error thrown)\n// If formValue.user.profile is null, the expression returns undefined (no error thrown)\n// No manual null checks needed!\n```\n\n**This means you don't need to write defensive null checks:**\n\n```typescript\n// \u274C Unnecessary - Don't do this\nexpression: '!formValue.user || !formValue.user.profile || !formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName';\n\n// \u2705 Better - Safe by default\nexpression: '!formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName';\n```\n\nThe parser automatically handles null/undefined at every level of property access, making expressions cleaner and more maintainable.\n\n## Available Context Variables\n\nWhen expressions are evaluated, they have access to:\n\n```typescript\n{\n fieldValue: 'current field value',\n formValue: { /* entire form state */ },\n fieldPath: 'fieldName',\n externalData: { /* external application state, if configured */ }\n}\n```\n\n### External Data\n\nWhen you configure `externalData` in your FormConfig, it becomes available in expressions:\n\n```typescript\nconst config = {\n externalData: {\n userRole: computed(() => authService.role()),\n permissions: computed(() => authService.permissions()),\n },\n fields: [\n {\n key: 'adminField',\n type: 'input',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: \"externalData.userRole !== 'admin'\",\n },\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\n## Common Form Use Cases\n\n### Dynamic Visibility\n\n```typescript\n{\n key: 'companyName',\n type: 'input',\n value: '',\n label: 'Company Name',\n // Show only if user selects \"Employed\"\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.employmentStatus !== \"employed\"'\n }\n }\n ]\n}\n```\n\n### Conditional Required\n\n```typescript\n{\n key: 'taxId',\n type: 'input',\n value: '',\n label: 'Tax ID',\n // Required only for business accounts\n logic: [\n {\n type: 'required',\n condition: {\n type: 'javascript',\n expression: 'formValue.accountType === \"business\"'\n },\n }\n ]\n}\n```\n\n### Complex Conditions\n\n```typescript\n{\n key: 'personalConcierge',\n type: 'checkbox',\n value: false,\n label: 'Request Personal Concierge Service',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.membershipLevel !== \"vip\" || formValue.annualIncome < 100000'\n }\n }\n ]\n}\n```\n\n### String Methods in Expressions\n\n```typescript\n{\n key: 'discountCode',\n type: 'input',\n value: '',\n label: 'Discount Code',\n logic: [\n {\n type: 'required',\n condition: {\n type: 'javascript',\n expression: 'formValue.email.endsWith(\"@company.com\")'\n },\n }\n ]\n}\n```\n\n## What the Parser Prevents\n\nFor dynamic forms, the parser prevents:\n\n- \u2705 **Code Injection**: Can't execute `Function()`, `eval()`, or create new code\n- \u2705 **Prototype Pollution**: Can't access `constructor` or `__proto__`\n- \u2705 **Unsafe Operations**: Can't call methods that modify state or access globals\n\n## Custom Functions\n\nWhen providing custom functions for use in expressions, register them with the FunctionRegistryService:\n\n```typescript\n// \u2705 GOOD - Pure functions\nimport { FunctionRegistryService } from '@ng-forge/dynamic-forms';\n\nconst functionRegistry = inject(FunctionRegistryService);\n\nfunctionRegistry.registerCustomFunction('isValidEmail', (ctx) => {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(ctx.fieldValue);\n});\n\nfunctionRegistry.registerCustomFunction('isAdult', (ctx) => {\n return ctx.formValue.age >= 18;\n});\n```\n\nThen use them with `type: 'custom'`:\n\n```typescript\n{\n key: 'adultContent',\n type: 'checkbox',\n value: false,\n label: 'Access Adult Content',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'custom',\n expression: 'isAdult'\n }\n }\n ]\n}\n```\n\n**Important**: Only provide pure functions without side effects:\n\n```typescript\n// \u274C BAD - Side effects\nfunctionRegistry.registerCustomFunction('logValue', (ctx) => {\n console.log(ctx.fieldValue); // Side effect!\n trackAnalytics(ctx); // Side effect!\n return true;\n});\n```\n\n## Supported Expression Features\n\nIn JavaScript expressions (`type: 'javascript'`), you can use:\n\n| Feature | Examples |\n| --------------------- | --------------------------------------------------------------- |\n| **Property access** | `formValue.name`, `formValue.user.profile.email` |\n| **Comparisons** | `===`, `!==`, `>`, `<`, `>=`, `<=` |\n| **Logical operators** | `&&`, `\\|\\|`, `!` |\n| **Arithmetic** | `+`, `-`, `*`, `/`, `%` |\n| **String methods** | See [Whitelisted Methods](#whitelisted-methods-reference) above |\n| **Array methods** | See [Whitelisted Methods](#whitelisted-methods-reference) above |\n\n**Example**:\n\n```typescript\nexpression: 'formValue.age >= 18 && formValue.email.includes(\"@example.com\")';\n```\n\n**Not Supported**: Object literals `{}`, ternary `a ? b : c`, assignment `x = 5`, `new` keyword, function declarations\n\n## Summary\n\nThe expression parser lets you write flexible conditional logic while preventing code injection attacks:\n\n1. **Form state is accessible** - Any field in `formValue` can be read\n2. **Methods are restricted** - Only safe, non-mutating methods allowed\n3. **Prototype is protected** - Can't access dangerous properties\n4. **Custom functions supported** - Register pure functions for reusable logic"
|
|
27
|
+
content: "Dynamic forms use expressions for conditional logic and dynamic values. When you use JavaScript expressions with `type: 'javascript'`, the expression parser evaluates them safely, preventing code injection attacks while maintaining the flexibility you need.\n\n## How JavaScript Expressions Work\n\nWhen you configure JavaScript expressions in your form:\n\n```typescript\n{\n key: 'driverLicense',\n type: 'input',\n value: '',\n label: 'Driver License',\n logic: [\n {\n type: 'required',\n condition: {\n type: 'javascript',\n expression: 'formValue.age >= 18 && formValue.needsTransport === true'\n },\n }\n ]\n}\n```\n\nThe expression `'formValue.age >= 18 && formValue.needsTransport === true'` is evaluated by the parser, which has access to:\n\n```typescript\n{\n fieldValue: currentFieldValue,\n formValue: { age: 25, needsTransport: true, ... },\n fieldPath: 'driverLicense'\n}\n```\n\n## Security Model\n\nThe parser uses different rules for methods and properties:\n\n### Methods: Whitelist Only\n\nOnly safe methods can be called in expressions:\n\n```typescript\n// \u2705 Works - safe string methods\n{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.email.includes(\"@company.com\")'\n }\n}\n\n// \u2705 Works - safe array methods\n{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.roles.some((role) => role === \"admin\")'\n }\n}\n\n// \u274C Blocked - not whitelisted\n{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.text.link(\"url\")' // Error: Method \"link\" is not allowed\n }\n}\n```\n\n### Whitelisted Methods Reference\n\n| Category | Methods |\n| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **String** | `toUpperCase`, `toLowerCase`, `trim`, `includes`, `startsWith`, `endsWith`, `slice`, `substring`, `charAt`, `indexOf`, `lastIndexOf`, `split`, `replace`, `match` |\n| **Array** | `map`, `filter`, `some`, `every`, `find`, `findIndex`, `includes`, `indexOf`, `join`, `slice`, `flat`, `flatMap` |\n| **Number** | `toFixed`, `toString`, `valueOf` |\n| **Object** | `hasOwnProperty`, `toString`, `valueOf` |\n\n### Properties: Open Access (Except Dangerous Ones)\n\nAll properties in the form data are accessible, except prototype properties:\n\n```typescript\n// \u2705 Works - access any form field\nexpression: 'formValue.firstName';\nexpression: 'formValue._internalFlag';\nexpression: 'formValue.nested.deeply.property';\n\n// \u274C Blocked - prototype pollution risks\nexpression: 'formValue.constructor'; // Error: Property \"constructor\" is not accessible\nexpression: 'formValue.__proto__'; // Error: Property \"__proto__\" is not accessible\n```\n\n**Why?** Dynamic forms need to access any field name you define. The parser blocks only the dangerous properties that could break security.\n\n### Safe Member Access (Built-in)\n\n**Important**: Member access is safe by default - accessing properties on `null` or `undefined` returns `undefined` instead of throwing errors:\n\n```typescript\n// \u2705 All of these work safely, even when intermediate values are null/undefined\nexpression: 'formValue.user.profile.firstName';\nexpression: 'formValue.address.city.toLowerCase()';\nexpression: 'formValue.settings.notifications.email';\n\n// If formValue.user is null, the expression returns undefined (no error thrown)\n// If formValue.user.profile is null, the expression returns undefined (no error thrown)\n// No manual null checks needed!\n```\n\n**This means you don't need to write defensive null checks:**\n\n```typescript\n// \u274C Unnecessary - Don't do this\nexpression: '!formValue.user || !formValue.user.profile || !formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName';\n\n// \u2705 Better - Safe by default\nexpression: '!formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName';\n```\n\nThe parser automatically handles null/undefined at every level of property access, making expressions cleaner and more maintainable.\n\n## Available Context Variables\n\nWhen expressions are evaluated, they have access to:\n\n```typescript\n{\n fieldValue: 'current field value',\n formValue: { /* entire form state */ },\n fieldPath: 'fieldName',\n externalData: { /* external application state, if configured */ }\n}\n```\n\n### External Data\n\nWhen you configure `externalData` in your FormConfig, it becomes available in expressions:\n\n```typescript\nconst config = {\n externalData: {\n userRole: computed(() => authService.role()),\n permissions: computed(() => authService.permissions()),\n },\n fields: [\n {\n key: 'adminField',\n type: 'input',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: \"externalData.userRole !== 'admin'\",\n },\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\n## Common Form Use Cases\n\n### Dynamic Visibility\n\n```typescript\n{\n key: 'companyName',\n type: 'input',\n value: '',\n label: 'Company Name',\n // Show only if user selects \"Employed\"\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.employmentStatus !== \"employed\"'\n }\n }\n ]\n}\n```\n\n### Conditional Required\n\n```typescript\n{\n key: 'taxId',\n type: 'input',\n value: '',\n label: 'Tax ID',\n // Required only for business accounts\n logic: [\n {\n type: 'required',\n condition: {\n type: 'javascript',\n expression: 'formValue.accountType === \"business\"'\n },\n }\n ]\n}\n```\n\n### Complex Conditions\n\n```typescript\n{\n key: 'personalConcierge',\n type: 'checkbox',\n value: false,\n label: 'Request Personal Concierge Service',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.membershipLevel !== \"vip\" || formValue.annualIncome < 100000'\n }\n }\n ]\n}\n```\n\n### String Methods in Expressions\n\n```typescript\n{\n key: 'discountCode',\n type: 'input',\n value: '',\n label: 'Discount Code',\n logic: [\n {\n type: 'required',\n condition: {\n type: 'javascript',\n expression: 'formValue.email.endsWith(\"@company.com\")'\n },\n }\n ]\n}\n```\n\n## What the Parser Prevents\n\nFor dynamic forms, the parser prevents:\n\n- \u2705 **Code Injection**: Can't execute arbitrary code or create new code\n- \u2705 **Prototype Pollution**: Can't access `constructor` or `__proto__`\n- \u2705 **Unsafe Operations**: Can't call methods that modify state or access globals\n\n## Custom Functions\n\nWhen providing custom functions for use in expressions, register them with the FunctionRegistryService:\n\n```typescript\n// \u2705 GOOD - Pure functions\nimport { FunctionRegistryService } from '@ng-forge/dynamic-forms';\n\nconst functionRegistry = inject(FunctionRegistryService);\n\nfunctionRegistry.registerCustomFunction('isValidEmail', (ctx) => {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(ctx.fieldValue);\n});\n\nfunctionRegistry.registerCustomFunction('isAdult', (ctx) => {\n return ctx.formValue.age >= 18;\n});\n```\n\nThen use them with `type: 'custom'`:\n\n```typescript\n{\n key: 'adultContent',\n type: 'checkbox',\n value: false,\n label: 'Access Adult Content',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'custom',\n expression: 'isAdult'\n }\n }\n ]\n}\n```\n\n**Important**: Only provide pure functions without side effects:\n\n```typescript\n// \u274C BAD - Side effects\nfunctionRegistry.registerCustomFunction('logValue', (ctx) => {\n console.log(ctx.fieldValue); // Side effect!\n trackAnalytics(ctx); // Side effect!\n return true;\n});\n```\n\n## Supported Expression Features\n\nIn JavaScript expressions (`type: 'javascript'`), you can use:\n\n| Feature | Examples |\n| --------------------- | --------------------------------------------------------------- |\n| **Property access** | `formValue.name`, `formValue.user.profile.email` |\n| **Comparisons** | `===`, `!==`, `>`, `<`, `>=`, `<=` |\n| **Logical operators** | `&&`, `\\|\\|`, `!` |\n| **Arithmetic** | `+`, `-`, `*`, `/`, `%` |\n| **String methods** | See [Whitelisted Methods](#whitelisted-methods-reference) above |\n| **Array methods** | See [Whitelisted Methods](#whitelisted-methods-reference) above |\n\n**Example**:\n\n```typescript\nexpression: 'formValue.age >= 18 && formValue.email.includes(\"@example.com\")';\n```\n\n**Not Supported**: Object literals `{}`, ternary `a ? b : c`, assignment `x = 5`, `new` keyword, function declarations\n\n## Summary\n\nThe expression parser lets you write flexible conditional logic while preventing code injection attacks:\n\n1. **Form state is accessible** - Any field in `formValue` can be read\n2. **Methods are restricted** - Only safe, non-mutating methods allowed\n3. **Prototype is protected** - Can't access dangerous properties\n4. **Custom functions supported** - Register pure functions for reusable logic"
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
id: "advanced-type-safety-basics",
|
|
@@ -32,17 +32,23 @@ var require_docs = __commonJS({
|
|
|
32
32
|
category: "advanced",
|
|
33
33
|
content: "Complete type inference for form configurations using TypeScript's type system and Angular signal forms.\n\n## Overview\n\nDynamic Forms provides compile-time type inference for form configurations, eliminating manual type definitions and catching errors before runtime. All form values are automatically inferred from your configuration using TypeScript's advanced type features.\n\n## Basic Type Inference\n\nUse `as const satisfies FormConfig` to enable type inference:\n\n```typescript\nimport { FormConfig } from '@ng-forge/dynamic-forms';\n\nconst formConfig = {\n fields: [\n { key: 'firstName', type: 'input', value: '' },\n { key: 'email', type: 'input', value: '' },\n { key: 'age', type: 'input', value: 0, props: { type: 'number' } },\n { key: 'newsletter', type: 'checkbox', value: false },\n ],\n} as const satisfies FormConfig;\n\n// Type is automatically inferred based on value property:\n// { firstName?: string; email?: string; age?: number; newsletter?: boolean }\n```\n\n**How it works:**\n\n1. `as const` makes the configuration deeply readonly, preserving literal types\n2. `satisfies FormConfig` validates the config structure without widening types\n3. TypeScript infers the form value type from the `value` properties\n\n## The `value` Property\n\nThe `value` property is critical for type inference - its type determines the inferred field type:\n\n```typescript\nconst config = {\n fields: [\n { key: 'name', type: 'input', value: '' }, // string\n { key: 'age', type: 'input', value: 0 }, // number\n { key: 'active', type: 'checkbox', value: false }, // boolean\n { key: 'tags', type: 'multi-checkbox', value: [] }, // string[]\n ],\n} as const satisfies FormConfig;\n\n// Inferred type:\n// {\n// name?: string;\n// age?: number;\n// active?: boolean;\n// tags?: string[];\n// }\n```\n\n## Field Registry System\n\nUI integrations register their field types via `provideDynamicForm`. For example, with Material Design:\n\n```typescript\n// app.config.ts\nimport { provideDynamicForm } from '@ng-forge/dynamic-forms';\nimport { withMaterialFields } from '@ng-forge/dynamic-forms-material';\n\nexport const appConfig: ApplicationConfig = {\n providers: [\n provideDynamicForm(...withMaterialFields()),\n // other providers\n ],\n};\n```\n\nMaterial fields augment the `FieldRegistryLeaves` interface for type safety:\n\n```typescript\n// From @ng-forge/dynamic-forms-material (already included)\ndeclare module '@ng-forge/dynamic-forms' {\n interface FieldRegistryLeaves {\n input: MatInputField;\n select: MatSelectField<unknown>;\n checkbox: MatCheckboxField;\n // ... other Material fields\n }\n}\n```\n\nThis determines:\n\n- What field `type` values are valid (e.g., `'input'`, `'select'`)\n- How each field type is structured\n- What props are available for each field\n\n**Example - Wrong field type:**\n\n```typescript\nconst config = {\n fields: [\n { key: 'name', type: 'invalid', value: '' }, // TypeScript error!\n ],\n} as const satisfies FormConfig;\n```\n\n## Required vs Optional Fields\n\nThe `required` flag affects whether fields include `undefined` in their type:\n\n```typescript\nconst config = {\n fields: [\n { key: 'email', type: 'input', value: '', required: true }, // string (required)\n { key: 'name', type: 'input', value: '' }, // string | undefined\n { key: 'age', type: 'input', value: 0, required: false }, // number | undefined\n ],\n} as const satisfies FormConfig;\n\n// Inferred type:\n// {\n// email: string; // Required - no undefined\n// name?: string; // Optional - includes undefined\n// age?: number; // Optional - includes undefined\n// }\n```\n\n**In practice:**\n\n```typescript\nfunction handleSubmit(value: InferFormValue<typeof config.fields>) {\n console.log(value.email.toUpperCase()); // \u2713 Safe - always defined\n console.log(value.name?.toUpperCase()); // \u2713 Must use optional chaining\n console.log(value.age + 1); // \u2717 Error - might be undefined\n}\n```\n\n## Using InferFormValue\n\nExtract the inferred type for use in your component:\n\n```typescript\nimport { signal } from '@angular/core';\nimport { InferFormValue } from '@ng-forge/dynamic-forms';\n\n@Component({...})\nexport class UserFormComponent {\n config = {\n fields: [\n { key: 'username', type: 'input', value: '', required: true },\n { key: 'age', type: 'input', value: 0 },\n ]\n } as const satisfies FormConfig;\n\n // Extract type for signal\n formValue = signal<InferFormValue<typeof this.config.fields>>({\n username: '',\n });\n\n onSubmit() {\n const value = this.formValue();\n console.log(value.username); // \u2713 Type: string (required)\n console.log(value.age); // \u2713 Type: number | undefined (optional)\n }\n}\n```\n\n## IntelliSense Support\n\nTypeScript provides autocomplete for form values throughout your code:\n\n```typescript\nconst config = {\n fields: [\n { key: 'firstName', type: 'input', value: '' },\n { key: 'lastName', type: 'input', value: '' },\n { key: 'email', type: 'input', value: '', required: true },\n ],\n} as const satisfies FormConfig;\n\ntype FormValue = InferFormValue<typeof config.fields>;\n\nfunction processForm(value: FormValue) {\n // IntelliSense suggests: firstName, lastName, email\n value.firstName; // \u2713 Autocomplete works\n value.lastName; // \u2713 Autocomplete works\n value.email; // \u2713 Autocomplete works\n value.invalid; // \u2717 TypeScript error - property doesn't exist\n}\n```\n\n## Best Practices\n\n### Always Use `as const satisfies FormConfig`\n\nThis enables type inference and validates your configuration:\n\n```typescript\n// \u2713 Correct - enables inference + type checking\nconst config = { fields: [...] } as const satisfies FormConfig;\n\n// \u2717 Wrong - no inference\nconst config = { fields: [...] };\n\n// \u2717 Wrong - too wide, loses precision\nconst config: FormConfig = { fields: [...] };\n```\n\n### Define Configs as Constants\n\nExport form configs as constants for reuse and consistent typing:\n\n```typescript\n// form-configs.ts\nexport const USER_FORM = {\n fields: [\n { key: 'name', type: 'input', value: '', required: true },\n { key: 'email', type: 'input', value: '', required: true },\n ],\n} as const satisfies FormConfig;\n\n// user-form.component.ts\nimport { USER_FORM } from './form-configs';\n\n@Component({...})\nexport class UserFormComponent {\n config = USER_FORM;\n}\n```\n\n### Always Include `value` Property\n\nType inference requires the `value` property to determine field types:\n\n```typescript\n// \u2713 Good - type inferred from value\nconst config = {\n fields: [\n { key: 'age', type: 'input', value: 0 }, // number\n { key: 'name', type: 'input', value: '' }, // string\n { key: 'active', type: 'checkbox', value: false }, // boolean\n ],\n} as const satisfies FormConfig;\n\n// \u2717 Bad - without value, type inference may not work correctly\nconst config = {\n fields: [\n { key: 'age', type: 'input' }, // Type unclear\n ],\n} as const satisfies FormConfig;\n```\n\n### Extract Types When Needed\n\nCreate type aliases for complex form values:\n\n```typescript\nimport { InferFormValue } from '@ng-forge/dynamic-forms';\n\nconst REGISTRATION_FORM = {\n fields: [\n { key: 'username', type: 'input', value: '', required: true },\n { key: 'email', type: 'input', value: '', required: true },\n { key: 'password', type: 'input', value: '', required: true },\n ],\n} as const satisfies FormConfig;\n\n// Export type for reuse\nexport type RegistrationFormValue = InferFormValue<(typeof REGISTRATION_FORM)['fields']>;\n\n// Use in functions\nfunction registerUser(data: RegistrationFormValue) {\n console.log(data.username, data.email, data.password);\n}\n```\n\n## Troubleshooting\n\n### Type inference not working\n\nType inference requires `as const` - without it, TypeScript treats your config as mutable:\n\n```typescript\n// \u2717 No inference - types are too wide\nconst config = {\n fields: [{ key: 'name', type: 'input', value: '' }],\n};\n// Type: { fields: { key: string; type: string; value: string }[] }\n\n// \u2713 With as const - precise inference\nconst config = {\n fields: [{ key: 'name', type: 'input', value: '' }],\n} as const satisfies FormConfig;\n// Type inferred: { name?: string }\n```\n\n### `satisfies` vs type annotation\n\nDon't use type annotation (`const config: FormConfig = ...`) as it widens types:\n\n```typescript\n// \u2717 Wrong - type is widened, inference fails\nconst config: FormConfig = {\n fields: [{ key: 'name', type: 'input', value: '' }],\n} as const;\n\n// \u2713 Correct - satisfies validates without widening\nconst config = {\n fields: [{ key: 'name', type: 'input', value: '' }],\n} as const satisfies FormConfig;\n```\n\n### Dynamic form configs\n\nType inference only works for **static**, **compile-time constant** configurations:\n\n```typescript\n// \u2717 Dynamic - no inference possible\nconst fields = getFieldsFromAPI(); // Returns field array at runtime\nconst config = { fields } as const satisfies FormConfig;\n// Can't infer - TypeScript doesn't know what getFieldsFromAPI() returns\n\n// For dynamic forms, manually type your form values:\ninterface MyFormValue {\n name: string;\n email: string;\n}\n\nconst formValue = signal<MyFormValue>({ name: '', email: '' });\n```\n\nIf your form configuration is built dynamically (from API data, conditional logic, or runtime calculations), you'll need to manually define the form value type.\n\n## Container Fields\n\nUnderstanding how container fields affect type inference and form value structure.\n\n### Container Fields Overview\n\nContainer fields organize form layout and structure without directly contributing values. There are three types:\n\n- **Group Fields** - Nest children under a single key\n- **Row Fields** - Organize fields horizontally, flatten children to parent level\n- **Page Fields** - Multi-step forms, flatten children to parent level\n\nEach container type has different behavior for type inference and form values.\n\n### Group Fields\n\nGroups nest children under a single key, creating nested objects in the form value:\n\n```typescript\nconst config = {\n fields: [\n {\n type: 'group',\n key: 'address',\n label: 'Address Information',\n fields: [\n { key: 'street', type: 'input', value: '' },\n { key: 'city', type: 'input', value: '' },\n { key: 'zip', type: 'input', value: '' },\n ],\n },\n ],\n} as const satisfies FormConfig;\n\n// Inferred type:\n// {\n// address: {\n// street?: string;\n// city?: string;\n// zip?: string;\n// }\n// }\n```\n\n**Use groups when:**\n\n- You want nested form values (e.g., `address.street`)\n- Grouping related fields logically\n- Creating reusable field sections\n\n**Example - Nested address:**\n\n```typescript\nconst config = {\n fields: [\n { key: 'name', type: 'input', value: '', required: true },\n {\n type: 'group',\n key: 'address',\n fields: [\n { key: 'street', type: 'input', value: '', required: true },\n { key: 'city', type: 'input', value: '', required: true },\n { key: 'state', type: 'select', value: '', required: true, options: [] },\n { key: 'zip', type: 'input', value: '' },\n ],\n },\n ],\n} as const satisfies FormConfig;\n\n// Form value structure:\n// {\n// name: string;\n// address: {\n// street: string;\n// city: string;\n// state: string;\n// zip?: string;\n// }\n// }\n\nfunction onSubmit(value: InferFormValue<typeof config.fields>) {\n console.log(value.name); // string\n console.log(value.address.street); // string\n console.log(value.address.city); // string\n console.log(value.address.zip); // string | undefined\n}\n```\n\n### Row Fields\n\nRows organize fields horizontally for layout purposes, but flatten children to the parent level in form values:\n\n```typescript\nconst config = {\n fields: [\n {\n type: 'row',\n fields: [\n { key: 'firstName', type: 'input', value: '' },\n { key: 'lastName', type: 'input', value: '' },\n ],\n },\n ],\n} as const satisfies FormConfig;\n\n// Inferred type (flattened):\n// {\n// firstName?: string;\n// lastName?: string;\n// }\n```\n\n**Use rows when:**\n\n- You want horizontal layout (grid columns)\n- Fields should be at the parent level (not nested)\n- Creating responsive multi-column forms\n\n**Row vs Group:**\n\n```typescript\n// Row - fields are flattened\n{\n type: 'row',\n fields: [\n { key: 'firstName', type: 'input', value: '' },\n { key: 'lastName', type: 'input', value: '' },\n ],\n}\n// Result: { firstName?: string, lastName?: string }\n\n// Group - fields are nested\n{\n type: 'group',\n key: 'name',\n fields: [\n { key: 'firstName', type: 'input', value: '' },\n { key: 'lastName', type: 'input', value: '' },\n ],\n}\n// Result: { name: { firstName?: string, lastName?: string } }\n```\n\n### Page Fields\n\nPages organize multi-step forms, flattening all children to the root level:\n\n```typescript\nconst config = {\n fields: [\n {\n key: 'page1',\n type: 'page',\n fields: [\n { key: 'page1Title', type: 'text', label: 'Personal Information', props: { elementType: 'h3' } },\n { key: 'email', type: 'input', value: '' },\n { key: 'password', type: 'input', value: '' },\n ],\n },\n {\n key: 'page2',\n type: 'page',\n fields: [\n { key: 'page2Title', type: 'text', label: 'Profile Details', props: { elementType: 'h3' } },\n { key: 'firstName', type: 'input', value: '' },\n { key: 'lastName', type: 'input', value: '' },\n ],\n },\n ],\n} as const satisfies FormConfig;\n\n// Inferred type (all pages flattened):\n// {\n// email?: string;\n// password?: string;\n// firstName?: string;\n// lastName?: string;\n// }\n```\n\n**Use pages when:**\n\n- Creating multi-step forms (wizard-style)\n- Fields from all steps should be at root level\n- You want step-by-step validation\n\n### Nesting Rules\n\nContainer fields enforce nesting constraints at compile-time to prevent invalid structures.\n\n**Visual Reference:**\n\n```\n\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 NESTING RULES \u2502\n\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 \u2502\n\u2502 PAGE \u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u25BA ROW \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u25BA GROUP \u2500\u2500\u252C\u2500\u2500\u25BA LEAF FIELD \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502\n\u2502 \u251C\u2500\u2500\u25BA GROUP \u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u25BA ROW \u2500\u2500\u2500\u2500\u2534\u2500\u2500\u25BA LEAF FIELD \u2502\n\u2502 \u2502 \u2502 \u2502\n\u2502 \u2514\u2500\u2500\u25BA LEAF FIELD \u2514\u2500\u2500\u25BA LEAF FIELD \u2502\n\u2502 \u2502\n\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 \u2717 PAGE cannot contain PAGE \u2502\n\u2502 \u2717 ROW cannot contain ROW or PAGE \u2502\n\u2502 \u2717 GROUP cannot contain GROUP or PAGE \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\n**Valid Nesting:**\n\n- **Pages** can contain: rows, groups, leaf fields (not other pages)\n- **Rows** can contain: groups, leaf fields (not pages or rows)\n- **Groups** can contain: rows, leaf fields (not pages or other groups)\n\n**Invalid Nesting:**\n\nTypeScript will prevent these invalid structures:\n\n```typescript\n// \u2717 Invalid: page inside page\nconst config1 = {\n fields: [\n {\n type: 'page',\n fields: [\n { type: 'page', fields: [] }, // TypeScript error!\n ],\n },\n ],\n} as const satisfies FormConfig;\n\n// \u2717 Invalid: row inside row\nconst config2 = {\n fields: [\n {\n type: 'row',\n fields: [\n { type: 'row', fields: [] }, // TypeScript error!\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\n### Value-Bearing vs Display Fields\n\nFields are categorized by whether they contribute to form values:\n\n**Value-Bearing Fields:**\n\nFields with a `value` property contribute to the form output:\n\n- `input`, `select`, `checkbox`, `textarea`, `datepicker`, `slider`, `toggle`, etc.\n\n**Display-Only Fields:**\n\nFields without values are excluded from form values:\n\n- `text` - displays content\n- `page`, `row`, `group` - container fields (children may have values)\n- Buttons - `submit`, `reset`, navigation buttons\n- Custom display components\n\n```typescript\nconst config = {\n fields: [\n { type: 'text', label: 'Enter your details:' }, // \u2717 Excluded\n { key: 'name', type: 'input', value: '' }, // \u2713 Included\n { type: 'submit', label: 'Save' }, // \u2717 Excluded\n {\n type: 'row', // \u2717 Excluded (container)\n fields: [\n { key: 'city', type: 'input', value: '' }, // \u2713 Included\n ],\n },\n ],\n} as const satisfies FormConfig;\n\n// Only value-bearing fields:\n// {\n// name?: string;\n// city?: string;\n// }\n```\n\n### Array Fields\n\nMulti-select fields and multi-checkbox fields return arrays:\n\n```typescript\nconst config = {\n fields: [\n {\n key: 'skills',\n type: 'select',\n value: [],\n props: { multiple: true },\n options: [\n { value: 'js', label: 'JavaScript' },\n { value: 'ts', label: 'TypeScript' },\n { value: 'go', label: 'Go' },\n ],\n },\n {\n key: 'interests',\n type: 'multi-checkbox',\n value: [],\n options: [\n { value: 'sports', label: 'Sports' },\n { value: 'music', label: 'Music' },\n { value: 'reading', label: 'Reading' },\n ],\n },\n ],\n} as const satisfies FormConfig;\n\n// Type: { skills?: string[]; interests?: string[] }\n```\n\n## Advanced Patterns\n\n### Runtime Validation\n\nForm values are typed at compile-time via `InferFormValue`, but runtime validation may be needed:\n\n#### Type Assertion (Trust Form Structure)\n\nIf you trust the form structure, cast the value:\n\n```typescript\nimport { InferFormValue } from '@ng-forge/dynamic-forms';\n\nconst USER_FORM = {\n fields: [\n { key: 'username', type: 'input', value: '', required: true },\n { key: 'email', type: 'input', value: '', required: true },\n { key: 'age', type: 'input', value: 0 },\n ],\n} as const satisfies FormConfig;\n\ntype UserFormValue = InferFormValue<(typeof USER_FORM)['fields']>;\n\nfunction onSubmit(value: unknown) {\n // Cast to inferred type\n const data = value as UserFormValue;\n\n console.log(data.username); // string\n console.log(data.email); // string\n console.log(data.age); // number | undefined\n}\n```\n\n#### Runtime Validation with Zod\n\nFor runtime guarantees, use a validation library like Zod:\n\n```typescript\nimport { z } from 'zod';\nimport { InferFormValue } from '@ng-forge/dynamic-forms';\n\nconst USER_FORM = {\n fields: [\n { key: 'username', type: 'input', value: '', required: true },\n { key: 'email', type: 'input', value: '', required: true },\n { key: 'age', type: 'input', value: 0 },\n ],\n} as const satisfies FormConfig;\n\n// Define Zod schema matching form structure\nconst userSchema = z.object({\n username: z.string().min(1),\n email: z.string().email(),\n age: z.number().optional(),\n});\n\nfunction onSubmit(value: unknown) {\n // Runtime validation\n const result = userSchema.safeParse(value);\n\n if (!result.success) {\n console.error('Validation failed:', result.error);\n return;\n }\n\n // Type-safe access\n const data = result.data;\n console.log(data.username); // string\n console.log(data.email); // string\n console.log(data.age); // number | undefined\n}\n```\n\n#### Combining Compile-Time and Runtime\n\nUse both for maximum safety:\n\n```typescript\nimport { z } from 'zod';\nimport { InferFormValue } from '@ng-forge/dynamic-forms';\n\nconst REGISTRATION_FORM = {\n fields: [\n { key: 'username', type: 'input', value: '', required: true },\n { key: 'email', type: 'input', value: '', required: true },\n { key: 'password', type: 'input', value: '', required: true },\n { key: 'confirmPassword', type: 'input', value: '', required: true },\n ],\n} as const satisfies FormConfig;\n\n// Compile-time type\ntype RegistrationValue = InferFormValue<typeof REGISTRATION_FORM['fields']>;\n\n// Runtime schema (with additional validation)\nconst registrationSchema = z.object({\n username: z.string().min(3).max(20),\n email: z.string().email(),\n password: z.string().min(8),\n confirmPassword: z.string(),\n}).refine((data) => data.password === data.confirmPassword, {\n message: \"Passwords don't match\",\n path: ['confirmPassword'],\n});\n\n@Component({...})\nexport class RegistrationComponent {\n config = REGISTRATION_FORM;\n\n // Compile-time type for signal\n formValue = signal<RegistrationValue>({\n username: '',\n email: '',\n password: '',\n confirmPassword: '',\n });\n\n onSubmit() {\n const value = this.formValue();\n\n // Runtime validation with cross-field check\n const result = registrationSchema.safeParse(value);\n\n if (!result.success) {\n console.error('Validation failed:', result.error);\n return;\n }\n\n // Safe to use\n this.register(result.data);\n }\n}\n```\n\n### Type-Safe Validation\n\nng-forge integrates validation with the type system. Validators affect the inferred type:\n\n#### Shorthand Validators\n\nShorthand validators are simple and affect type inference:\n\n```typescript\nconst config = {\n fields: [\n {\n key: 'email',\n type: 'input',\n value: '',\n required: true, // Removes undefined from type\n email: true, // Email validation\n minLength: 5, // Min length validation\n },\n ],\n} as const satisfies FormConfig;\n\n// Type inferred: { email: string } (required, so no undefined)\n```\n\n#### Validator Array\n\nFor complex validation, use the `validators` array:\n\n```typescript\nconst config = {\n fields: [\n {\n key: 'discount',\n type: 'input',\n value: 0,\n validators: [\n {\n type: 'min',\n value: 0,\n },\n {\n type: 'max',\n value: 100,\n when: {\n type: 'fieldValue',\n fieldPath: 'discountType',\n operator: 'equals',\n value: 'percentage',\n },\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\n#### Conditional Required\n\nUse the `logic` array for conditional required fields:\n\n```typescript\nconst config = {\n fields: [\n { key: 'accountType', type: 'select', value: '', options: [] },\n {\n key: 'taxId',\n type: 'input',\n value: '',\n logic: [\n {\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n\n// Type: { accountType?: string; taxId?: string }\n// Note: Conditional required doesn't affect type inference\n```\n\n## Related\n\n- **[Validation](../../validation/basics/)** - How validators affect inferred types\n- **[Field Types](../../schema-fields/field-types/)** - Understanding available field types\n- **[Form Layout](../../prebuilt/form-groups/)** - Visual guide to container fields"
|
|
34
34
|
},
|
|
35
|
+
{
|
|
36
|
+
id: "advanced-value-exclusion",
|
|
37
|
+
title: "Advanced Value Exclusion",
|
|
38
|
+
category: "advanced",
|
|
39
|
+
content: "Value exclusion controls which field values are included in the form submission output based on the field's reactive state (hidden, disabled, readonly).\n\n## Overview\n\nBy default, field values are **excluded** from the `(submitted)` output when the field is hidden, disabled, or readonly. This prevents stale or irrelevant data from being sent to the server.\n\n**Key point:** Value exclusion only affects the submission output. Internal form controls retain their values so they can be restored when a field becomes visible, enabled, or editable again. Two-way binding via `[value]` / `[(value)]` is **not affected**.\n\n## Default Behavior\n\nWhen no configuration is specified, the defaults are:\n\n| Rule | Default | Effect |\n| ------------------------ | ------- | --------------------------------------------------- |\n| `excludeValueIfHidden` | `true` | Hidden field values are excluded from submission |\n| `excludeValueIfDisabled` | `true` | Disabled field values are excluded from submission |\n| `excludeValueIfReadonly` | `true` | Read-only field values are excluded from submission |\n\n> **Breaking change from pre-1.0:** Previously, all field values were always included. To restore the old behavior, see [Migration from Previous Versions](#migration-from-previous-versions).\n\n## 3-Tier Configuration\n\nValue exclusion supports a 3-tier configuration hierarchy. The most specific level wins for each property:\n\n| Priority | Level | Where to set |\n| -------- | ------ | ----------------------------------------------- |\n| 1 (wins) | Field | `excludeValueIf*` on individual `FieldDef` |\n| 2 | Form | `excludeValueIf*` on `FormOptions` |\n| 3 | Global | `withValueExclusionDefaults()` feature function |\n\nIf a property is `undefined` at a given level, the next level down is checked.\n\n## Global Configuration\n\nUse `withValueExclusionDefaults()` in your provider setup to configure global defaults:\n\n```typescript\nimport { provideDynamicForm, withValueExclusionDefaults } from '@ng-forge/dynamic-forms';\n\n// All exclusions enabled (same as default)\nprovideDynamicForm(...withMaterialFields(), withValueExclusionDefaults());\n\n// Disable readonly exclusion globally\nprovideDynamicForm(...withMaterialFields(), withValueExclusionDefaults({ excludeValueIfReadonly: false }));\n\n// Disable all exclusions globally (pre-1.0 behavior)\nprovideDynamicForm(\n ...withMaterialFields(),\n withValueExclusionDefaults({\n excludeValueIfHidden: false,\n excludeValueIfDisabled: false,\n excludeValueIfReadonly: false,\n }),\n);\n```\n\n## Form-Level Configuration\n\nOverride the global setting for a specific form using `FormOptions`:\n\n```typescript\nconst config: FormConfig = {\n fields: [...],\n options: {\n // Include hidden field values for this form only\n excludeValueIfHidden: false,\n // Use global default for disabled and readonly\n }\n};\n```\n\n## Field-Level Configuration\n\nOverride both global and form settings for a specific field:\n\n```typescript\nconst config: FormConfig = {\n fields: [\n {\n type: 'input',\n key: 'internalId',\n label: 'Internal ID',\n readonly: true,\n // Always include this field's value, even when readonly\n excludeValueIfReadonly: false,\n },\n {\n type: 'input',\n key: 'notes',\n label: 'Notes',\n // This field uses the form/global defaults\n },\n ],\n};\n```\n\n## Resolution Example\n\nGiven these settings:\n\n- **Global:** `excludeValueIfHidden: true`\n- **Form:** `excludeValueIfHidden: false`\n- **Field A:** `excludeValueIfHidden: undefined` (not set)\n- **Field B:** `excludeValueIfHidden: true`\n\nResolution:\n\n- **Field A** uses form-level (`false`) because field-level is `undefined` → value is **included** when hidden\n- **Field B** uses field-level (`true`) → value is **excluded** when hidden\n\n## HiddenField Type\n\nThe `HiddenField` type (`type: 'hidden'`) is **not affected** by value exclusion. These fields:\n\n- Store values without rendering any UI\n- Have a `hidden()` reactive state of `false` (they are part of the form, just not rendered)\n- Are always included in submission output\n\nValue exclusion applies to the `hidden` **property** on regular fields (e.g., `{ type: 'input', hidden: true }` or fields hidden via conditional logic), not to the `HiddenField` type.\n\n## What's Excluded vs Retained\n\n| Aspect | Affected by exclusion? | Details |\n| -------------------------- | ---------------------- | ---------------------------------------- |\n| `(submitted)` output | Yes | Excluded fields are omitted |\n| `filteredFormValue` signal | Yes | Same filtering as submission |\n| `formValue` signal | No | Always contains all values |\n| `[(value)]` binding | No | Two-way binding retains all values |\n| Internal form controls | No | Fields keep their values for restoration |\n\n## Migration from Previous Versions\n\nIf you relied on all field values being present in `(submitted)` output, disable exclusion globally:\n\n```typescript\nprovideDynamicForm(\n ...withMaterialFields(),\n withValueExclusionDefaults({\n excludeValueIfHidden: false,\n excludeValueIfDisabled: false,\n excludeValueIfReadonly: false,\n }),\n);\n```\n\nOr disable per-form via `FormOptions`:\n\n```typescript\nconst config: FormConfig = {\n fields: [...],\n options: {\n excludeValueIfHidden: false,\n excludeValueIfDisabled: false,\n excludeValueIfReadonly: false,\n }\n};\n```"
|
|
40
|
+
},
|
|
35
41
|
{
|
|
36
42
|
id: "ai-integration",
|
|
37
43
|
title: "AI Integration",
|
|
38
44
|
category: "ai-integration",
|
|
39
|
-
content: '# AI Integration\n\nThe `@ng-forge/dynamic-form-mcp` package provides a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI assistants to generate, validate, and work with ng-forge dynamic form configurations.\n\n## Available Tools\n\nThe MCP server provides 4 focused tools:\n\n| Tool | Description | Read-only |\n| ------------------ | ----------------------------------------------------------- | --------- |\n| `ngforge_lookup` | Get documentation about field types, concepts, and patterns | \u2705 |\n| `ngforge_examples` | Get working code examples for common form patterns | \u2705 |\n| `ngforge_validate` | Validate FormConfig and get detailed error feedback | \u2705 |\n| `ngforge_scaffold` | Generate valid FormConfig skeletons | \u2705 |\n\n## Get Started\n\n### Cursor\n\nAdd to your Cursor MCP settings:\n\n```json\n{\n "ng-forge": {\n "command": "npx",\n "args": ["-y", "@ng-forge/dynamic-form-mcp"]\n }\n}\n```\n\n### VS Code with Copilot\n\nCreate `.vscode/mcp.json` in your project:\n\n```json\n{\n "servers": {\n "ng-forge": {\n "command": "npx",\n "args": ["-y", "@ng-forge/dynamic-form-mcp"]\n }\n }\n}\n```\n\n### Claude Desktop\n\nAdd to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):\n\n```json\n{\n "mcpServers": {\n "ng-forge": {\n "command": "npx",\n "args": ["-y", "@ng-forge/dynamic-form-mcp"]\n }\n }\n}\n```\n\n### JetBrains IDEs\n\nGo to **Settings > Tools > AI Assistant > Model Context Protocol (MCP)** and add:\n\n| Field | Value |\n| --------- | ----------------------------- |\n| Name | ng-forge |\n| Command | npx |\n| Arguments | -y @ng-forge/dynamic-form-mcp |\n\n------------ | --------------------------------------------------------- | ---------- | ----------------------- |\n| `topic` | string | (required) | Topic to look up |\n| `depth` | `"brief"` \\| `"full"` \\| `"schema"` | `"full"` | Level of detail |\n| `uiIntegration` | `"material"` \\| `"bootstrap"` \\| `"primeng"` \\| `"ionic"` | - | Filter UI-specific info |\n\n**Available Topics:**\n\n| Category | Topics
|
|
45
|
+
content: '# AI Integration\n\nThe `@ng-forge/dynamic-form-mcp` package provides a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI assistants to generate, validate, and work with ng-forge dynamic form configurations.\n\n## Available Tools\n\nThe MCP server provides 4 focused tools:\n\n| Tool | Description | Read-only |\n| ------------------ | ----------------------------------------------------------- | --------- |\n| `ngforge_lookup` | Get documentation about field types, concepts, and patterns | \u2705 |\n| `ngforge_examples` | Get working code examples for common form patterns | \u2705 |\n| `ngforge_validate` | Validate FormConfig and get detailed error feedback | \u2705 |\n| `ngforge_scaffold` | Generate valid FormConfig skeletons | \u2705 |\n\n## Get Started\n\n### Cursor\n\nAdd to your Cursor MCP settings:\n\n```json\n{\n "ng-forge": {\n "command": "npx",\n "args": ["-y", "@ng-forge/dynamic-form-mcp"]\n }\n}\n```\n\n### VS Code with Copilot\n\nCreate `.vscode/mcp.json` in your project:\n\n```json\n{\n "servers": {\n "ng-forge": {\n "command": "npx",\n "args": ["-y", "@ng-forge/dynamic-form-mcp"]\n }\n }\n}\n```\n\n### Claude Desktop\n\nAdd to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):\n\n```json\n{\n "mcpServers": {\n "ng-forge": {\n "command": "npx",\n "args": ["-y", "@ng-forge/dynamic-form-mcp"]\n }\n }\n}\n```\n\n### JetBrains IDEs\n\nGo to **Settings > Tools > AI Assistant > Model Context Protocol (MCP)** and add:\n\n| Field | Value |\n| --------- | ----------------------------- |\n| Name | ng-forge |\n| Command | npx |\n| Arguments | -y @ng-forge/dynamic-form-mcp |\n\n------------ | --------------------------------------------------------- | ---------- | ----------------------- |\n| `topic` | string | (required) | Topic to look up |\n| `depth` | `"brief"` \\| `"full"` \\| `"schema"` | `"full"` | Level of detail |\n| `uiIntegration` | `"material"` \\| `"bootstrap"` \\| `"primeng"` \\| `"ionic"` | - | Filter UI-specific info |\n\n**Available Topics:**\n\n| Category | Topics |\n| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Field Types | `input`, `select`, `radio`, `checkbox`, `textarea`, `datepicker`, `slider`, `toggle`, `hidden`, `text`, `button`, `submit`, `next`, `previous`, `addArrayItem`, `prependArrayItem`, `insertArrayItem`, `removeArrayItem`, `popArrayItem`, `shiftArrayItem` |\n| Containers | `group`, `row`, `array`, `page` |\n| Concepts | `validation`, `conditional`, `derivation`, `options-format`, `expression-variables`, `async-validators`, `validation-messages` |\n| Patterns | `golden-path`, `multi-page-gotchas`, `pitfalls`, `workflow` |\n\n**Examples:**\n\n```\nngforge_lookup topic="hidden" depth="brief"\nngforge_lookup topic="conditional" depth="full"\nngforge_lookup topic="input" depth="schema" uiIntegration="material"\n```\n\n---\n\n### ngforge_examples\n\nGet working code examples for common patterns.\n\n| Parameter | Type | Default | Description |\n| --------- | ----------------------------------------------------- | ---------- | --------------- |\n| `pattern` | string | (required) | Pattern name |\n| `depth` | `"minimal"` \\| `"brief"` \\| `"full"` \\| `"explained"` | `"full"` | Level of detail |\n\n**Available Patterns:**\n\n| Pattern | Description | Lines |\n| --------------------- | ---------------------------------------- | ----- |\n| `minimal-multipage` | Simplest 2-page wizard form | ~50 |\n| `minimal-array` | Array with add/remove buttons | ~30 |\n| `minimal-conditional` | Show/hide field based on condition | ~25 |\n| `minimal-validation` | Password confirmation validation | ~20 |\n| `minimal-hidden` | Hidden fields in multi-page form | ~15 |\n| `derivation` | Value derivation (computed fields) | - |\n| `conditional` | Conditional visibility patterns | - |\n| `multi-page` | Multi-step wizard forms | - |\n| `validation` | Form validation patterns | - |\n| `complete` | Full form with all major features | - |\n| `mega` | Kitchen sink demonstrating every feature | - |\n\n**Examples:**\n\n```\nngforge_examples pattern="minimal-multipage" depth="minimal"\nngforge_examples pattern="conditional" depth="explained"\n```\n\n---\n\n### ngforge_validate\n\nValidate FormConfig and get detailed error feedback.\n\n| Parameter | Type | Default | Description |\n| --------------- | --------------------------------------------------------- | ------------ | ------------------------------ |\n| `config` | string \\| object | (required) | File path or JSON config |\n| `uiIntegration` | `"material"` \\| `"bootstrap"` \\| `"primeng"` \\| `"ionic"` | `"material"` | UI library to validate against |\n\n**Input Detection:**\n\n| Input | Treated As |\n| ------------------------ | ------------------ |\n| Ends with `.ts` or `.js` | File path |\n| Starts with `{` or `[` | JSON string |\n| Object | Validated directly |\n\n**Example Errors:**\n\n- "Hidden field missing REQUIRED value property"\n- "options MUST be at FIELD level, NOT inside props"\n- "row containers do NOT support logic blocks"\n\n**Examples:**\n\n```\nngforge_validate config="/path/to/form.config.ts"\nngforge_validate config=\'{"fields":[...]}\' uiIntegration="bootstrap"\n```\n\n---\n\n### ngforge_scaffold\n\nGenerate valid FormConfig skeletons.\n\n| Parameter | Type | Default | Description |\n| --------------- | -------- | ------------ | ------------------------------------- |\n| `pages` | number | `0` | Number of pages (0 = single-page) |\n| `arrays` | string[] | `[]` | Array field names |\n| `groups` | string[] | `[]` | Group field names |\n| `hidden` | string[] | `[]` | Hidden fields as `"name:value"` pairs |\n| `fields` | string[] | `[]` | Fields as `"name:type"` pairs |\n| `uiIntegration` | enum | `"material"` | UI library |\n\n**Supported Types for `fields`:**\n\n`input`, `select`, `radio`, `checkbox`, `textarea`, `datepicker`, `slider`, `toggle`\n\n**Examples:**\n\n```\nngforge_scaffold pages=0 fields=["name:input","email:input"]\nngforge_scaffold pages=3 arrays=["contacts"] groups=["address"]\nngforge_scaffold hidden=["userId:abc123","source:web"]\n```\n\n---\n\n## MCP Resources\n\nIn addition to tools, the server exposes resources that AI can read:\n\n| Resource URI | Description |\n| -------------------------- | ---------------------------------------------- |\n| `ng-forge://instructions` | Best practices guide for generating FormConfig |\n| `ng-forge://examples` | Curated FormConfig examples |\n| `ng-forge://examples/{id}` | Specific example by ID |\n| `ng-forge://field-types` | Field type reference |\n| `ng-forge://validators` | Validator reference |\n| `ng-forge://ui-adapters` | UI library configurations |\n| `ng-forge://docs` | Full documentation index |\n\n---\n\n## Feedback\n\nFound an issue or have a suggestion? [Open an issue on GitHub](https://github.com/ng-forge/ng-forge/issues).'
|
|
40
46
|
},
|
|
41
47
|
{
|
|
42
48
|
id: "dynamic-behavior-conditional-logic-overview",
|
|
43
49
|
title: "Dynamic Behavior Conditional Logic Overview",
|
|
44
50
|
category: "dynamic-behavior",
|
|
45
|
-
content: "Control field behavior dynamically based on form state. Dynamic forms provides a declarative API for conditional visibility, required state, and readonly state that maps directly to Angular's signal forms.\n\n## Signal Forms Integration\n\nThe library integrates with Angular's signal forms logic functions:\n\n```typescript\nimport { hidden, readonly, required } from '@angular/forms/signals';\n```\n\nAll conditional logic configuration is applied using these functions, providing:\n\n- **Reactive updates** when form state changes\n- **Type-safe** conditional expressions\n- **Automatic re-evaluation** on dependencies\n- **Integration** with form validation state\n\n## Static Properties\n\nUse simple boolean properties for fields with fixed states.\n\n### hidden\n\nHide a field from view (field still participates in form state):\n\n```typescript\n{\n key: 'internalId',\n type: 'input',\n value: '',\n label: 'Internal ID',\n hidden: true,\n}\n```\n\nThe field is hidden from the UI but still included in the form value.\n\n### disabled\n\nDisable user interaction:\n\n```typescript\n{\n key: 'systemField',\n type: 'input',\n value: 'auto-generated',\n label: 'System Field',\n disabled: true,\n}\n```\n\n**Note:** The `disabled` property is handled at the component level and does not use signal forms logic functions. It's a static UI property that prevents user interaction.\n\n### readonly\n\nMake a field read-only (displays value but prevents modification):\n\n```typescript\n{\n key: 'createdAt',\n type: 'input',\n value: '2024-01-15',\n label: 'Created Date',\n readonly: true,\n}\n```\n\n## Dynamic Conditional Logic\n\nFor conditional behavior based on form state, use the `logic` array with `LogicConfig` objects.\n\n```typescript\ninterface LogicConfig {\n /** Logic type */\n type: 'hidden' | 'readonly' | 'disabled' | 'required';\n\n /** Boolean expression, static value, or form state condition */\n condition: ConditionalExpression | boolean | FormStateCondition;\n}\n```\n\n`FormStateCondition` values (`'formInvalid'`, `'formSubmitting'`, `'pageInvalid'`) are primarily used for button disabled logic.\n\n### Conditional Visibility (hidden)\n\nShow or hide fields based on other field values.\n\n#### Show Email When Contact Method is Email\n\n```typescript\n{\n key: 'contactMethod',\n type: 'select',\n value: '',\n options: [\n { value: 'email', label: 'Email' },\n { value: 'phone', label: 'Phone' },\n ],\n}\n{\n key: 'email',\n type: 'input',\n value: '',\n label: 'Email Address',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'contactMethod',\n operator: 'notEquals',\n value: 'email',\n },\n }],\n}\n```\n\nWhen `contactMethod !== 'email'`, the email field is hidden.\n\n### Conditional Required\n\nMake fields required based on conditions.\n\n#### Tax ID Required for Business Accounts\n\n```typescript\n{\n key: 'accountType',\n type: 'radio',\n value: 'personal',\n options: [\n { value: 'personal', label: 'Personal' },\n { value: 'business', label: 'Business' },\n ],\n}\n{\n key: 'taxId',\n type: 'input',\n value: '',\n label: 'Tax ID',\n logic: [{\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n }],\n}\n```\n\n### Conditional Readonly\n\nMake fields read-only based on conditions.\n\n#### Lock Field After Submission\n\n```typescript\n{\n key: 'status',\n type: 'select',\n value: 'draft',\n options: [\n { value: 'draft', label: 'Draft' },\n { value: 'submitted', label: 'Submitted' },\n ],\n}\n{\n key: 'documentNumber',\n type: 'input',\n value: '',\n label: 'Document Number',\n logic: [{\n type: 'readonly',\n condition: {\n type: 'fieldValue',\n fieldPath: 'status',\n operator: 'equals',\n value: 'submitted',\n },\n }],\n}\n```\n\nOnce status is \"submitted\", the document number becomes read-only.\n\n## Basic Conditional Expression\n\nThe most common conditional expression checks a specific field's value:\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'fieldKey',\n operator: 'equals',\n value: 'expectedValue',\n}\n```\n\n**Components:**\n\n- `type: 'fieldValue'` - Check a specific field\n- `fieldPath` - The field key to check\n- `operator` - Comparison operator (see [All Operators](#all-operators))\n- `value` - Value to compare against\n\n## Quick Example\n\nShow a field only when another field has a specific value:\n\n```typescript\n{\n key: 'email',\n type: 'input',\n value: '',\n label: 'Email Address',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'contactMethod',\n operator: 'notEquals',\n value: 'email',\n },\n },\n {\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'contactMethod',\n operator: 'equals',\n value: 'email',\n },\n },\n ],\n}\n```\n\nThis field is hidden unless `contactMethod === 'email'`, and required when visible. See the [Examples](/docs/examples) page for complete form implementations.\n\n## When Logic Runs\n\nConditional logic is evaluated:\n\n- **On form value change** - Any time a dependent field changes\n- **On initialization** - When the form is created\n- **Reactively** - Uses Angular's signal forms for automatic updates\n\n### Evaluation Flow\n\n```\n\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 LOGIC EVALUATION FLOW \u2502\n\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 \u2502\n\u2502 Form Value Changes \u2502\n\u2502 \u2502 \u2502\n\u2502 \u25BC \u2502\n\u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 \u2502 Signal Updates \u2502\u25C4\u2500\u2500\u2500\u2500 Angular's reactive system \u2502\n\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\n\u2502 \u2502 \u2502\n\u2502 \u25BC \u2502\n\u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 \u2502 Evaluate logic \u2502 \u2502\n\u2502 \u2502 conditions \u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\n\u2502 \u2502 \u2502\n\u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 \u25BC \u25BC \u2502\n\u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 \u2502 true \u2502 \u2502false \u2502 \u2502\n\u2502 \u2514\u2500\u2500\u252C\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u252C\u2500\u2500\u2500\u2518 \u2502\n\u2502 \u2502 \u2502 \u2502\n\u2502 \u25BC \u25BC \u2502\n\u2502 Apply Remove \u2502\n\u2502 effect effect \u2502\n\u2502 (hide, (show, \u2502\n\u2502 require, optional, \u2502\n\u2502 readonly) editable) \u2502\n\u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\n## Expression Types\n\n### fieldValue\n\nCheck a specific field's value - the most common expression type.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n}\n```\n\n**Use when:** Checking a single field's value\n\n**Example:**\n\n```typescript\n{\n key: 'companyName',\n type: 'input',\n value: '',\n logic: [{\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n }],\n}\n```\n\n### formValue\n\nCompare the entire form value object against a specific value using operators.\n\n```typescript\n{\n type: 'formValue',\n operator: 'equals',\n value: { status: 'active', role: 'admin' },\n}\n```\n\n**Use when:** Checking if the entire form matches a specific state\n\n**Note:** For complex logic involving multiple fields with JavaScript expressions, use `javascript` or `custom` type instead.\n\n### javascript\n\nJavaScript expressions with access to `fieldValue` (current field) and `formValue` (entire form).\n\n```typescript\n{\n type: 'javascript',\n expression: 'new Date(fieldValue) > new Date()',\n}\n```\n\n**Use when:** Custom logic on field value or complex multi-field conditions\n\n**Examples:**\n\n```typescript\n// Check current field value\n{\n key: 'eventDate',\n type: 'datepicker',\n value: null,\n logic: [{\n type: 'readonly',\n condition: {\n type: 'javascript',\n expression: 'new Date(fieldValue) < new Date()',\n },\n }],\n}\n\n// Check multiple form fields (replaces old formValue expression pattern)\n{\n key: 'stateProvince',\n type: 'select',\n value: '',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.country !== \"US\" && formValue.country !== \"CA\"',\n },\n }],\n}\n```\n\n**Safe member access:** Accessing nested properties on `null` or `undefined` returns `undefined` (no errors thrown):\n\n```typescript\n{\n type: 'javascript',\n // Safe even when user, profile, or preferences is null/undefined\n expression: 'formValue.user.profile.preferences.notifications === true',\n}\n```\n\n### custom\n\nAdvanced custom expressions with access to both field and form values.\n\n```typescript\n{\n type: 'custom',\n expression: 'fieldValue > formValue.minAge && fieldValue < formValue.maxAge',\n}\n```\n\n**Safe member access:** Like `formValue` expressions, nested property access is safe:\n\n```typescript\n{\n type: 'custom',\n // Safe even when nested values are null/undefined\n expression: 'fieldValue !== formValue.user.profile.firstName',\n}\n```\n\n## All Operators\n\n### Equality Operators\n\n#### equals\n\nExact match comparison.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'status',\n operator: 'equals',\n value: 'active',\n}\n```\n\n#### notEquals\n\nNot equal to comparison.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'status',\n operator: 'notEquals',\n value: 'archived',\n}\n```\n\n### Comparison Operators\n\n#### greater\n\nGreater than comparison (numbers/dates).\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greater',\n value: 18,\n}\n```\n\n#### less\n\nLess than comparison.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'quantity',\n operator: 'less',\n value: 100,\n}\n```\n\n#### greaterOrEqual\n\nGreater than or equal to.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greaterOrEqual',\n value: 21,\n}\n```\n\n#### lessOrEqual\n\nLess than or equal to.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'discount',\n operator: 'lessOrEqual',\n value: 100,\n}\n```\n\n### String Operators\n\n#### contains\n\nCheck if string/array contains value.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'email',\n operator: 'contains',\n value: '@company.com',\n}\n```\n\n#### startsWith\n\nCheck if string starts with value.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'url',\n operator: 'startsWith',\n value: 'https://',\n}\n```\n\n#### endsWith\n\nCheck if string ends with value.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'email',\n operator: 'endsWith',\n value: '.gov',\n}\n```\n\n#### matches\n\nRegular expression match.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'zipCode',\n operator: 'matches',\n value: '^[0-9]{5}$',\n}\n```\n\n## Combining Conditions\n\n### AND Logic\n\nAll conditions must be true.\n\n```typescript\n{\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n {\n type: 'fieldValue',\n fieldPath: 'hasTeam',\n operator: 'equals',\n value: true,\n },\n {\n type: 'fieldValue',\n fieldPath: 'teamSize',\n operator: 'greater',\n value: 5,\n },\n ],\n}\n```\n\n**Use case:** Field required when all conditions are met.\n\n```typescript\n{\n key: 'enterpriseFeatures',\n type: 'checkbox',\n label: 'Enable Enterprise Features',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n {\n type: 'fieldValue',\n fieldPath: 'plan',\n operator: 'equals',\n value: 'enterprise',\n },\n ],\n },\n }],\n}\n```\n\n### OR Logic\n\nAt least one condition must be true.\n\n```typescript\n{\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'role',\n operator: 'equals',\n value: 'admin',\n },\n {\n type: 'fieldValue',\n fieldPath: 'role',\n operator: 'equals',\n value: 'owner',\n },\n ],\n}\n```\n\n**Use case:** Show field for multiple roles.\n\n```typescript\n{\n key: 'adminPanel',\n type: 'group',\n label: 'Administration',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'role',\n operator: 'notEquals',\n value: 'admin',\n },\n {\n type: 'fieldValue',\n fieldPath: 'role',\n operator: 'notEquals',\n value: 'owner',\n },\n ],\n },\n }],\n}\n```\n\n### Nested Logic\n\nCombine AND/OR logic for complex conditions.\n\n```typescript\n{\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'US',\n },\n {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greaterOrEqual',\n value: 21,\n },\n {\n type: 'fieldValue',\n fieldPath: 'hasParentalConsent',\n operator: 'equals',\n value: true,\n },\n ],\n },\n ],\n}\n```\n\nThis means: \"Country must be US AND (age >= 21 OR has parental consent)\"\n\n## Practical Examples\n\n### Show Field Based on Multiple Conditions\n\n```typescript\n{\n key: 'internationalShipping',\n type: 'checkbox',\n label: 'Enable International Shipping',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'free',\n },\n {\n type: 'fieldValue',\n fieldPath: 'verified',\n operator: 'equals',\n value: false,\n },\n ],\n },\n }],\n}\n```\n\nHidden for free accounts OR unverified accounts.\n\n### Required Field with Complex Logic\n\n```typescript\n{\n key: 'taxExemptionNumber',\n type: 'input',\n value: '',\n label: 'Tax Exemption Number',\n logic: [{\n type: 'required',\n condition: {\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n {\n type: 'fieldValue',\n fieldPath: 'claimsTaxExemption',\n operator: 'equals',\n value: true,\n },\n {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'US',\n },\n {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'CA',\n },\n ],\n },\n ],\n },\n }],\n}\n```\n\n### Dynamic Read-Only Based on Status\n\n```typescript\n{\n key: 'orderItems',\n type: 'group',\n label: 'Order Items',\n logic: [{\n type: 'readonly',\n condition: {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'orderStatus',\n operator: 'equals',\n value: 'shipped',\n },\n {\n type: 'fieldValue',\n fieldPath: 'orderStatus',\n operator: 'equals',\n value: 'delivered',\n },\n {\n type: 'fieldValue',\n fieldPath: 'orderStatus',\n operator: 'equals',\n value: 'cancelled',\n },\n ],\n },\n }],\n}\n```\n\nOrder items become read-only once order is shipped, delivered, or cancelled.\n\n## Best Practices\n\n**Keep conditions readable:**\n\n```typescript\n// \u2705 Good - Easy to understand\n{\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n}\n\n// \u274C Avoid - Hard to maintain\n{\n type: 'formValue',\n expression: 'formValue.accountType === \"business\" && formValue.country !== null && formValue.hasTeam',\n}\n```\n\n## ConditionalExpression Interface\n\n```typescript\ninterface ConditionalExpression {\n /** Expression type - includes 'and' and 'or' for combining conditions */\n type: 'fieldValue' | 'formValue' | 'javascript' | 'custom' | 'and' | 'or';\n\n /** Field path for fieldValue type */\n fieldPath?: string;\n\n /**\n * Comparison operator\n * - For 'fieldValue': compares field at fieldPath against value\n * - For 'formValue': compares entire form object against value\n */\n operator?:\n | 'equals'\n | 'notEquals'\n | 'greater'\n | 'less'\n | 'greaterOrEqual'\n | 'lessOrEqual'\n | 'contains'\n | 'startsWith'\n | 'endsWith'\n | 'matches';\n\n /** Value to compare against (for fieldValue/formValue with operator) */\n value?: unknown;\n\n /**\n * JavaScript expression string\n * - For 'javascript': Has access to fieldValue and formValue\n * - For 'custom': Name of registered custom function\n */\n expression?: string;\n\n /** Array of sub-conditions for 'and' and 'or' types */\n conditions?: ConditionalExpression[];\n}\n```\n\n**Expression types summary:**\n\n| Type | Uses | Purpose |\n| ------------ | -------------------------------- | ---------------------------------------------- |\n| `fieldValue` | `fieldPath`, `operator`, `value` | Compare a specific field's value |\n| `formValue` | `operator`, `value` | Compare entire form object |\n| `javascript` | `expression` | Custom JS with `fieldValue`/`formValue` access |\n| `custom` | `expression` | Call registered custom function |\n| `and`/`or` | `conditions` | Combine multiple conditions |\n\n## Common Patterns\n\n### Show/Hide Field Pattern\n\n```typescript\nlogic: [\n {\n type: 'hidden',\n condition: {\n /* when to hide */\n },\n },\n];\n```\n\n### Conditional Required Pattern\n\n```typescript\nlogic: [\n {\n type: 'hidden',\n condition: {\n /* when to hide */\n },\n },\n {\n type: 'required',\n condition: {\n /* when to require */\n },\n },\n];\n```\n\n### Multiple Conditions Pattern\n\n```typescript\nlogic: [\n {\n type: 'hidden',\n condition: {\n type: 'and', // or 'or'\n conditions: [\n {\n /* condition 1 */\n },\n {\n /* condition 2 */\n },\n ],\n },\n },\n];\n```\n\n## External Data in Conditions\n\nAccess external application state (user roles, permissions, feature flags) in conditions using `externalData`:\n\n```typescript\nconst config = {\n externalData: {\n userRole: computed(() => authService.role()),\n featureFlags: computed(() => ({\n advancedMode: featureService.isAdvanced(),\n })),\n },\n fields: [\n {\n key: 'adminNotes',\n type: 'textarea',\n label: 'Admin Notes',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: \"externalData.userRole !== 'admin'\",\n },\n },\n ],\n },\n {\n key: 'advancedSettings',\n type: 'input',\n label: 'Advanced Settings',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'externalData.featureFlags.advancedMode !== true',\n },\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\n**Key points:**\n\n- Each property in `externalData` must be a Signal (`signal()` or `computed()`)\n- Changes to external signals automatically re-evaluate conditions\n- Access values via `externalData.propertyName` in JavaScript expressions\n\n## Related\n\n- **[Value Derivation](../value-derivation/basics/)** - Computed field values\n- **[Validation](../../validation/basics/)** - Conditional validation\n- **[Type Safety](../../advanced/type-safety/basics/)** - TypeScript integration\n- **[Examples](/docs/examples)** - Real-world form patterns"
|
|
51
|
+
content: "Control field behavior dynamically based on form state. Dynamic forms provides a declarative API for conditional visibility, required state, and readonly state that maps directly to Angular's signal forms.\n\n## Signal Forms Integration\n\nThe library integrates with Angular's signal forms logic functions:\n\n```typescript\nimport { hidden, readonly, required } from '@angular/forms/signals';\n```\n\nAll conditional logic configuration is applied using these functions, providing:\n\n- **Reactive updates** when form state changes\n- **Type-safe** conditional expressions\n- **Automatic re-evaluation** on dependencies\n- **Integration** with form validation state\n\n## Static Properties\n\nUse simple boolean properties for fields with fixed states.\n\n### hidden\n\nHide a field from view (field still participates in form state):\n\n```typescript\n{\n key: 'internalId',\n type: 'input',\n value: '',\n label: 'Internal ID',\n hidden: true,\n}\n```\n\nThe field is hidden from the UI but still included in the form value.\n\n### disabled\n\nDisable user interaction:\n\n```typescript\n{\n key: 'systemField',\n type: 'input',\n value: 'auto-generated',\n label: 'System Field',\n disabled: true,\n}\n```\n\n**Note:** The `disabled` property is handled at the component level and does not use signal forms logic functions. It's a static UI property that prevents user interaction.\n\n### readonly\n\nMake a field read-only (displays value but prevents modification):\n\n```typescript\n{\n key: 'createdAt',\n type: 'input',\n value: '2024-01-15',\n label: 'Created Date',\n readonly: true,\n}\n```\n\n## Dynamic Conditional Logic\n\nFor conditional behavior based on form state, use the `logic` array with `LogicConfig` objects.\n\n```typescript\ninterface LogicConfig {\n /** Logic type */\n type: 'hidden' | 'readonly' | 'disabled' | 'required';\n\n /** Boolean expression, static value, or form state condition */\n condition: ConditionalExpression | boolean | FormStateCondition;\n}\n```\n\n`FormStateCondition` values (`'formInvalid'`, `'formSubmitting'`, `'pageInvalid'`) are primarily used for button disabled logic.\n\n### Conditional Visibility (hidden)\n\nShow or hide fields based on other field values.\n\n#### Show Email When Contact Method is Email\n\n```typescript\n{\n key: 'contactMethod',\n type: 'select',\n value: '',\n options: [\n { value: 'email', label: 'Email' },\n { value: 'phone', label: 'Phone' },\n ],\n}\n{\n key: 'email',\n type: 'input',\n value: '',\n label: 'Email Address',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'contactMethod',\n operator: 'notEquals',\n value: 'email',\n },\n }],\n}\n```\n\nWhen `contactMethod !== 'email'`, the email field is hidden.\n\n### Conditional Required\n\nMake fields required based on conditions.\n\n#### Tax ID Required for Business Accounts\n\n```typescript\n{\n key: 'accountType',\n type: 'radio',\n value: 'personal',\n options: [\n { value: 'personal', label: 'Personal' },\n { value: 'business', label: 'Business' },\n ],\n}\n{\n key: 'taxId',\n type: 'input',\n value: '',\n label: 'Tax ID',\n logic: [{\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n }],\n}\n```\n\n### Conditional Readonly\n\nMake fields read-only based on conditions.\n\n#### Lock Field After Submission\n\n```typescript\n{\n key: 'status',\n type: 'select',\n value: 'draft',\n options: [\n { value: 'draft', label: 'Draft' },\n { value: 'submitted', label: 'Submitted' },\n ],\n}\n{\n key: 'documentNumber',\n type: 'input',\n value: '',\n label: 'Document Number',\n logic: [{\n type: 'readonly',\n condition: {\n type: 'fieldValue',\n fieldPath: 'status',\n operator: 'equals',\n value: 'submitted',\n },\n }],\n}\n```\n\nOnce status is \"submitted\", the document number becomes read-only.\n\n## Basic Conditional Expression\n\nThe most common conditional expression checks a specific field's value:\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'fieldKey',\n operator: 'equals',\n value: 'expectedValue',\n}\n```\n\n**Components:**\n\n- `type: 'fieldValue'` - Check a specific field\n- `fieldPath` - The field key to check\n- `operator` - Comparison operator (see [All Operators](#all-operators))\n- `value` - Value to compare against\n\n## Quick Example\n\nShow a field only when another field has a specific value:\n\n```typescript\n{\n key: 'email',\n type: 'input',\n value: '',\n label: 'Email Address',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'contactMethod',\n operator: 'notEquals',\n value: 'email',\n },\n },\n {\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'contactMethod',\n operator: 'equals',\n value: 'email',\n },\n },\n ],\n}\n```\n\nThis field is hidden unless `contactMethod === 'email'`, and required when visible. See the [Examples](/examples) page for complete form implementations.\n\n## When Logic Runs\n\nConditional logic is evaluated:\n\n- **On form value change** - Any time a dependent field changes\n- **On initialization** - When the form is created\n- **Reactively** - Uses Angular's signal forms for automatic updates\n\n### Evaluation Flow\n\n```\n\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 LOGIC EVALUATION FLOW \u2502\n\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 \u2502\n\u2502 Form Value Changes \u2502\n\u2502 \u2502 \u2502\n\u2502 \u25BC \u2502\n\u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 \u2502 Signal Updates \u2502\u25C4\u2500\u2500\u2500\u2500 Angular's reactive system \u2502\n\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\n\u2502 \u2502 \u2502\n\u2502 \u25BC \u2502\n\u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 \u2502 Evaluate logic \u2502 \u2502\n\u2502 \u2502 conditions \u2502 \u2502\n\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\n\u2502 \u2502 \u2502\n\u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 \u25BC \u25BC \u2502\n\u2502 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 \u2502 true \u2502 \u2502false \u2502 \u2502\n\u2502 \u2514\u2500\u2500\u252C\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u252C\u2500\u2500\u2500\u2518 \u2502\n\u2502 \u2502 \u2502 \u2502\n\u2502 \u25BC \u25BC \u2502\n\u2502 Apply Remove \u2502\n\u2502 effect effect \u2502\n\u2502 (hide, (show, \u2502\n\u2502 require, optional, \u2502\n\u2502 readonly) editable) \u2502\n\u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\n## Expression Types\n\n### fieldValue\n\nCheck a specific field's value - the most common expression type.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n}\n```\n\n**Use when:** Checking a single field's value\n\n**Example:**\n\n```typescript\n{\n key: 'companyName',\n type: 'input',\n value: '',\n logic: [{\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n }],\n}\n```\n\n### formValue\n\nCompare the entire form value object against a specific value using operators.\n\n```typescript\n{\n type: 'formValue',\n operator: 'equals',\n value: { status: 'active', role: 'admin' },\n}\n```\n\n**Use when:** Checking if the entire form matches a specific state\n\n**Note:** This type is rarely useful in practice \u2014 deep equality on an entire form object is an unusual requirement. For conditions that involve multiple specific fields, use `javascript` or `custom` expressions instead (e.g. `formValue.status === 'active' && formValue.role === 'admin'`).\n\n### javascript\n\nJavaScript expressions with access to `fieldValue` (current field) and `formValue` (entire form).\n\n```typescript\n{\n type: 'javascript',\n expression: 'new Date(fieldValue) > new Date()',\n}\n```\n\n**Use when:** Custom logic on field value or complex multi-field conditions\n\n**Examples:**\n\n```typescript\n// Check current field value\n{\n key: 'eventDate',\n type: 'datepicker',\n value: null,\n logic: [{\n type: 'readonly',\n condition: {\n type: 'javascript',\n expression: 'new Date(fieldValue) < new Date()',\n },\n }],\n}\n\n// Check multiple form fields (replaces old formValue expression pattern)\n{\n key: 'stateProvince',\n type: 'select',\n value: '',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'formValue.country !== \"US\" && formValue.country !== \"CA\"',\n },\n }],\n}\n```\n\n**Safe member access:** Accessing nested properties on `null` or `undefined` returns `undefined` (no errors thrown):\n\n```typescript\n{\n type: 'javascript',\n // Safe even when user, profile, or preferences is null/undefined\n expression: 'formValue.user.profile.preferences.notifications === true',\n}\n```\n\n### custom\n\nAdvanced custom expressions with access to both field and form values.\n\n```typescript\n{\n type: 'custom',\n expression: 'fieldValue > formValue.minAge && fieldValue < formValue.maxAge',\n}\n```\n\n**Safe member access:** Like `formValue` expressions, nested property access is safe:\n\n```typescript\n{\n type: 'custom',\n // Safe even when nested values are null/undefined\n expression: 'fieldValue !== formValue.user.profile.firstName',\n}\n```\n\n### Field State in Expressions\n\n`javascript` and `custom` expressions have access to two additional variables for querying field interaction state:\n\n- **`fieldState`** \u2014 the current field's own state\n- **`formFieldState`** \u2014 state of any field in the form, by key\n\n#### fieldState\n\nUse `fieldState` to react to the current field's own interaction state:\n\n```typescript\n// Lock the field as soon as the user edits it\n{\n key: 'accountNumber',\n type: 'input',\n logic: [{\n type: 'readonly',\n condition: {\n type: 'javascript',\n expression: 'fieldState.dirty',\n },\n }],\n}\n```\n\n**Available properties:**\n\n| Property | Type | Description |\n| ---------- | --------- | ----------------------------------- |\n| `touched` | `boolean` | User has focused and left the field |\n| `dirty` | `boolean` | User has changed the field value |\n| `pristine` | `boolean` | Equivalent to `!dirty` |\n| `valid` | `boolean` | Field has no validation errors |\n| `invalid` | `boolean` | Field has validation errors |\n| `pending` | `boolean` | Async validation is in progress |\n| `hidden` | `boolean` | Field is currently hidden |\n| `readonly` | `boolean` | Field is currently readonly |\n| `disabled` | `boolean` | Field is currently disabled |\n\n#### formFieldState\n\nUse `formFieldState` to react to another field's state. Access by field key:\n\n```typescript\n// Make a field readonly once a related field has been touched\n{\n key: 'billingAddress',\n type: 'input',\n logic: [{\n type: 'readonly',\n condition: {\n type: 'javascript',\n expression: 'formFieldState.shippingAddress.dirty',\n },\n }],\n}\n```\n\n`formFieldState` has the same properties as `fieldState`, keyed by field name.\n\n**Example \u2014 show a confirmation field only after the primary field is dirty:**\n\n```typescript\n{\n key: 'confirmEmail',\n type: 'input',\n label: 'Confirm Email',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: '!formFieldState.email.dirty',\n },\n }],\n}\n```\n\n### http\n\nEvaluate a condition by sending an HTTP request and inspecting the response. The request fires automatically when declared query params change, with built-in debouncing.\n\n```typescript\n{\n type: 'http',\n http: {\n url: '/api/permissions',\n queryParams: {\n role: 'formValue.userRole',\n },\n },\n responseExpression: 'response.canEdit',\n pendingValue: false,\n}\n```\n\n**Use when:** Field visibility or state must be determined server-side (permissions, feature flags, country-specific rules).\n\n**Full example \u2014 hide an admin panel based on server permissions:**\n\n```typescript\n{\n key: 'adminPanel',\n type: 'input',\n label: 'Admin Panel Access Code',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'http',\n http: {\n url: '/api/permissions',\n queryParams: {\n role: 'formValue.userRole',\n },\n },\n responseExpression: 'response.hideAdminPanel',\n pendingValue: true, // Hide while checking\n },\n }],\n}\n```\n\n**HTTP condition properties:**\n\n| Property | Type | Required | Default | Description |\n| -------------------- | ------------------- | -------- | ------------ | ------------------------------------------------------------------------ |\n| `type` | `'http'` | Yes | \u2014 | Identifies this as an HTTP condition |\n| `http` | `HttpRequestConfig` | Yes | \u2014 | Request configuration (see below) |\n| `responseExpression` | `string` | No | `!!response` | Expression evaluated with `{ response }` in scope. Must return a boolean |\n| `pendingValue` | `boolean` | No | `false` | Value returned while the request is in-flight |\n| `cacheDurationMs` | `number` | No | `30000` | How long to cache responses (ms) |\n| `debounceMs` | `number` | No | `300` | Debounce delay before re-sending (ms) |\n\n**`HttpRequestConfig` quick reference:**\n\n| Property | Description |\n| ------------------------- | ----------------------------------------------------------------------------------- |\n| `url` | Request URL. Use `:key` placeholders for path parameters |\n| `method` | HTTP method. Defaults to `'GET'` |\n| `params` | Path parameters. `:key` placeholders in the URL replaced with evaluated expressions |\n| `queryParams` | Key/value map. Values are expressions evaluated against form context |\n| `body` | Request body for POST/PUT/PATCH |\n| `evaluateBodyExpressions` | When `true`, top-level `body` string values are evaluated as expressions |\n| `headers` | Request headers |\n\n**HTTP condition on `required` \u2014 server-driven required fields:**\n\n```typescript\n{\n key: 'taxId',\n type: 'input',\n logic: [{\n type: 'required',\n condition: {\n type: 'http',\n http: {\n url: '/api/tax-rules',\n queryParams: { country: 'formValue.country' },\n },\n responseExpression: 'response.taxIdRequired',\n pendingValue: false, // Optional while checking\n },\n }],\n}\n```\n\n### async\n\nEvaluate a condition using a custom async function registered in `customFnConfig.asyncConditions`. Functions receive the full form context and must return a `Promise<boolean>` or `Observable<boolean>`.\n\n```typescript\n{\n type: 'async',\n asyncFunctionName: 'checkPermission',\n pendingValue: false,\n}\n```\n\n**Use when:** Condition logic involves Angular service injection, complex async operations, or anything that `http` conditions cannot express directly.\n\n> **Why `inject()` works here:** `customFnConfig` functions are called within an Angular injection context, so Angular's `inject()` API is available \u2014 the same way it works in a constructor or field initializer. Import `inject` from `@angular/core` as usual.\n\n**Registration and usage:**\n\n```typescript\nimport { inject } from '@angular/core';\n\nconst formConfig = {\n customFnConfig: {\n asyncConditions: {\n checkReadonly: (context) => {\n return inject(PermissionsService).canEdit(context.formValue.resourceId as string);\n },\n },\n },\n\n fields: [\n {\n key: 'salary',\n type: 'input',\n label: 'Salary',\n logic: [\n {\n type: 'readonly',\n condition: {\n type: 'async',\n asyncFunctionName: 'checkReadonly',\n pendingValue: false, // Editable while checking\n },\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\n**Async condition properties:**\n\n| Property | Type | Required | Default | Description |\n| ------------------- | --------- | -------- | ------- | --------------------------------------------------- |\n| `type` | `'async'` | Yes | \u2014 | Identifies this as an async condition |\n| `asyncFunctionName` | `string` | Yes | \u2014 | Name registered in `customFnConfig.asyncConditions` |\n| `pendingValue` | `boolean` | No | `false` | Value returned while the function is resolving |\n| `debounceMs` | `number` | No | `300` | Debounce delay before re-evaluating (ms) |\n\n**Choosing `pendingValue`:**\n\nThe right `pendingValue` depends on the logic type and desired UX:\n\n| Logic type | `pendingValue: false` | `pendingValue: true` |\n| ---------- | ----------------------- | ----------------------- |\n| `hidden` | Visible while checking | Hidden while checking |\n| `required` | Optional while checking | Required while checking |\n| `readonly` | Editable while checking | Readonly while checking |\n| `disabled` | Enabled while checking | Disabled while checking |\n\n## All Operators\n\n### Equality Operators\n\n#### equals\n\nExact match comparison.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'status',\n operator: 'equals',\n value: 'active',\n}\n```\n\n#### notEquals\n\nNot equal to comparison.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'status',\n operator: 'notEquals',\n value: 'archived',\n}\n```\n\n### Comparison Operators\n\n#### greater\n\nGreater than comparison (numbers/dates).\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greater',\n value: 18,\n}\n```\n\n#### less\n\nLess than comparison.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'quantity',\n operator: 'less',\n value: 100,\n}\n```\n\n#### greaterOrEqual\n\nGreater than or equal to.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greaterOrEqual',\n value: 21,\n}\n```\n\n#### lessOrEqual\n\nLess than or equal to.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'discount',\n operator: 'lessOrEqual',\n value: 100,\n}\n```\n\n### String Operators\n\n#### contains\n\nCheck if string/array contains value.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'email',\n operator: 'contains',\n value: '@company.com',\n}\n```\n\n#### startsWith\n\nCheck if string starts with value.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'url',\n operator: 'startsWith',\n value: 'https://',\n}\n```\n\n#### endsWith\n\nCheck if string ends with value.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'email',\n operator: 'endsWith',\n value: '.gov',\n}\n```\n\n#### matches\n\nRegular expression match.\n\n```typescript\n{\n type: 'fieldValue',\n fieldPath: 'zipCode',\n operator: 'matches',\n value: '^[0-9]{5}$',\n}\n```\n\n## Combining Conditions\n\n### AND Logic\n\nAll conditions must be true.\n\n```typescript\n{\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n {\n type: 'fieldValue',\n fieldPath: 'hasTeam',\n operator: 'equals',\n value: true,\n },\n {\n type: 'fieldValue',\n fieldPath: 'teamSize',\n operator: 'greater',\n value: 5,\n },\n ],\n}\n```\n\n**Use case:** Field required when all conditions are met.\n\n```typescript\n{\n key: 'enterpriseFeatures',\n type: 'checkbox',\n label: 'Enable Enterprise Features',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n {\n type: 'fieldValue',\n fieldPath: 'plan',\n operator: 'equals',\n value: 'enterprise',\n },\n ],\n },\n }],\n}\n```\n\n### OR Logic\n\nAt least one condition must be true.\n\n```typescript\n{\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'role',\n operator: 'equals',\n value: 'admin',\n },\n {\n type: 'fieldValue',\n fieldPath: 'role',\n operator: 'equals',\n value: 'owner',\n },\n ],\n}\n```\n\n**Use case:** Show field for multiple roles \u2014 hide unless role is `admin` or `owner`.\n\n```typescript\n// Hide the panel when role is neither 'admin' nor 'owner'\n// (i.e. hidden when notEquals 'admin' AND notEquals 'owner')\n{\n key: 'adminPanel',\n type: 'group',\n label: 'Administration',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'role',\n operator: 'notEquals',\n value: 'admin',\n },\n {\n type: 'fieldValue',\n fieldPath: 'role',\n operator: 'notEquals',\n value: 'owner',\n },\n ],\n },\n }],\n}\n```\n\n### Nested Logic\n\nCombine AND/OR logic for complex conditions.\n\n```typescript\n{\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'US',\n },\n {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greaterOrEqual',\n value: 21,\n },\n {\n type: 'fieldValue',\n fieldPath: 'hasParentalConsent',\n operator: 'equals',\n value: true,\n },\n ],\n },\n ],\n}\n```\n\nThis means: \"Country must be US AND (age >= 21 OR has parental consent)\"\n\n## Practical Examples\n\n### Show Field Based on Multiple Conditions\n\n```typescript\n{\n key: 'internationalShipping',\n type: 'checkbox',\n label: 'Enable International Shipping',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'free',\n },\n {\n type: 'fieldValue',\n fieldPath: 'verified',\n operator: 'equals',\n value: false,\n },\n ],\n },\n }],\n}\n```\n\nHidden for free accounts OR unverified accounts.\n\n### Required Field with Complex Logic\n\n```typescript\n{\n key: 'taxExemptionNumber',\n type: 'input',\n value: '',\n label: 'Tax Exemption Number',\n logic: [{\n type: 'required',\n condition: {\n type: 'and',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n {\n type: 'fieldValue',\n fieldPath: 'claimsTaxExemption',\n operator: 'equals',\n value: true,\n },\n {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'US',\n },\n {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'CA',\n },\n ],\n },\n ],\n },\n }],\n}\n```\n\n### Dynamic Read-Only Based on Status\n\n```typescript\n{\n key: 'orderItems',\n type: 'group',\n label: 'Order Items',\n logic: [{\n type: 'readonly',\n condition: {\n type: 'or',\n conditions: [\n {\n type: 'fieldValue',\n fieldPath: 'orderStatus',\n operator: 'equals',\n value: 'shipped',\n },\n {\n type: 'fieldValue',\n fieldPath: 'orderStatus',\n operator: 'equals',\n value: 'delivered',\n },\n {\n type: 'fieldValue',\n fieldPath: 'orderStatus',\n operator: 'equals',\n value: 'cancelled',\n },\n ],\n },\n }],\n}\n```\n\nOrder items become read-only once order is shipped, delivered, or cancelled.\n\n## Best Practices\n\n**Keep conditions readable:**\n\n```typescript\n// \u2705 Good - Easy to understand\n{\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n}\n\n// \u274C Avoid - Hard to maintain\n{\n type: 'formValue',\n expression: 'formValue.accountType === \"business\" && formValue.country !== null && formValue.hasTeam',\n}\n```\n\n## ConditionalExpression Interface\n\n`ConditionalExpression` is a discriminated union of all expression types:\n\n```typescript\n// Sync expressions\ntype ConditionalExpression =\n | { type: 'fieldValue'; fieldPath: string; operator: Operator; value: unknown }\n | { type: 'formValue'; operator: Operator; value: unknown }\n | { type: 'javascript'; expression: string }\n | { type: 'custom'; expression: string }\n | { type: 'and'; conditions: ConditionalExpression[] }\n | { type: 'or'; conditions: ConditionalExpression[] }\n // Async expressions\n | HttpCondition\n | AsyncCondition;\n\ntype Operator =\n | 'equals'\n | 'notEquals'\n | 'greater'\n | 'less'\n | 'greaterOrEqual'\n | 'lessOrEqual'\n | 'contains'\n | 'startsWith'\n | 'endsWith'\n | 'matches';\n\ninterface HttpCondition {\n type: 'http';\n http: HttpRequestConfig;\n responseExpression?: string; // Evaluated with { response }. Defaults to !!response\n pendingValue?: boolean; // Default: false\n cacheDurationMs?: number; // Default: 30000\n debounceMs?: number; // Default: 300\n}\n\ninterface AsyncCondition {\n type: 'async';\n asyncFunctionName: string;\n pendingValue?: boolean; // Default: false\n debounceMs?: number; // Default: 300\n}\n```\n\n**Expression types summary:**\n\n| Type | Sync/Async | Key properties | Purpose |\n| ------------ | ---------- | -------------------------------- | --------------------------------------------------------------------- |\n| `fieldValue` | Sync | `fieldPath`, `operator`, `value` | Compare a specific field's value |\n| `formValue` | Sync | `operator`, `value` | Compare entire form object |\n| `javascript` | Sync | `expression` | Custom JS with `fieldValue`/`formValue`/`fieldState`/`formFieldState` |\n| `custom` | Sync | `expression` | Inline expression with `fieldValue`/`formValue` (safe member access) |\n| `and`/`or` | Sync | `conditions` | Combine multiple conditions |\n| `http` | Async | `http`, `responseExpression` | Server-driven condition via HTTP request |\n| `async` | Async | `asyncFunctionName` | Custom async function registered in config |\n\n## Common Patterns\n\n### Show/Hide Field Pattern\n\n```typescript\nlogic: [\n {\n type: 'hidden',\n condition: {\n /* when to hide */\n },\n },\n];\n```\n\n### Conditional Required Pattern\n\n```typescript\nlogic: [\n {\n type: 'hidden',\n condition: {\n /* when to hide */\n },\n },\n {\n type: 'required',\n condition: {\n /* when to require */\n },\n },\n];\n```\n\n### Multiple Conditions Pattern\n\n```typescript\nlogic: [\n {\n type: 'hidden',\n condition: {\n type: 'and', // or 'or'\n conditions: [\n {\n /* condition 1 */\n },\n {\n /* condition 2 */\n },\n ],\n },\n },\n];\n```\n\n## External Data in Conditions\n\nAccess external application state (user roles, permissions, feature flags) in conditions using `externalData`:\n\n```typescript\nconst config = {\n externalData: {\n userRole: computed(() => authService.role()),\n featureFlags: computed(() => ({\n advancedMode: featureService.isAdvanced(),\n })),\n },\n fields: [\n {\n key: 'adminNotes',\n type: 'textarea',\n label: 'Admin Notes',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: \"externalData.userRole !== 'admin'\",\n },\n },\n ],\n },\n {\n key: 'advancedSettings',\n type: 'input',\n label: 'Advanced Settings',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'javascript',\n expression: 'externalData.featureFlags.advancedMode !== true',\n },\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\n**Key points:**\n\n- Each property in `externalData` must be a Signal (`signal()` or `computed()`)\n- Changes to external signals automatically re-evaluate conditions\n- Access values via `externalData.propertyName` in JavaScript expressions\n\n## Related\n\n- **[Value Derivation](../value-derivation/basics/)** \u2014 Computed field values\n- **[Async Derivation](../value-derivation/async/)** \u2014 HTTP and async function derivations, stopOnUserOverride\n- **[Validation](../../validation/basics/)** \u2014 Conditional validation\n- **[Custom Validators](../../validation/custom-validators/)** \u2014 Async and HTTP validators\n- **[Type Safety](../../advanced/type-safety/basics/)** \u2014 TypeScript integration\n- **[Examples](/examples)** \u2014 Real-world form patterns"
|
|
46
52
|
},
|
|
47
53
|
{
|
|
48
54
|
id: "dynamic-behavior-i18n",
|
|
@@ -54,13 +60,25 @@ var require_docs = __commonJS({
|
|
|
54
60
|
id: "dynamic-behavior-submission",
|
|
55
61
|
title: "Dynamic Behavior Submission",
|
|
56
62
|
category: "dynamic-behavior",
|
|
57
|
-
content: "Form submission in dynamic forms integrates with Angular Signal Forms' native `submit()` function, providing automatic button disabling, loading states, and server error handling.\n\n## Overview\n\nThe submission system provides:\n\n- Async form submission with automatic loading state\n- Server-side validation error handling\n- Automatic submit button disabling during submission\n- Configurable button disabled behavior\n- Custom disabled logic via `FormStateCondition`\n\n**Note**: The submission mechanism is **optional** - you can still handle submission manually via the `(submitted)` output if you prefer.\n\n## Basic Usage\n\nConfigure form submission with the `submission` property:\n\n```typescript\nimport { Component, inject } from '@angular/core';\nimport { HttpClient } from '@angular/common/http';\nimport { FormConfig, InferFormValue } from '@ng-forge/dynamic-forms';\n\nconst formFields = {\n fields: [\n { type: 'input', key: 'email', label: 'Email', value: '', required: true, email: true },\n { type: 'input', key: 'password', label: 'Password', value: '', required: true, props: { type: 'password' } },\n { type: 'submit', key: 'submit', label: 'Sign In' },\n ],\n} as const satisfies FormConfig;\n\ntype LoginForm = InferFormValue<typeof formFields.fields>;\n\n@Component({...})\nexport class LoginFormComponent {\n private http = inject(HttpClient);\n\n config = {\n ...formFields,\n submission: {\n action: (form: LoginForm) => this.http.post('/api/login', form),\n },\n };\n}\n```\n\n## SubmissionConfig\n\nThe `submission.action` function receives the typed form value and can return an Observable or Promise:\n\n| Return Value | Meaning |\n| ---------------------- | ------------------------------------------ |\n| Completes successfully | Successful submission |\n| `TreeValidationResult` | Server validation errors (single or array) |\n\n**Note:** `TreeValidationResult` from Angular Signal Forms supports both single errors and arrays. You can return a single error object or an array of errors.\n\nWhile the action is executing, the form is in a submitting state, enabling automatic button disabling and loading states.\n\n### Server Error Handling\n\nReturn validation errors to apply them to specific fields using `catchError`:\n\n```typescript\nimport { catchError, of } from 'rxjs';\n\nsubmission: {\n action: (form: FormValue) => this.http.post('/api/register', form).pipe(\n catchError((error) => {\n if (error.error?.code === 'EMAIL_EXISTS') {\n return of([{\n field: 'email',\n error: { kind: 'server', message: 'Email already exists' },\n }]);\n }\n throw error; // Re-throw unexpected errors\n }),\n ),\n}\n```\n\n## Button Disabled Behavior\n\nSubmit and next page buttons are automatically disabled based on form state. Configure defaults via `options`:\n\n```typescript\nconst config: FormConfig = {\n fields: [...],\n options: {\n submitButton: {\n disableWhenInvalid: true, // Disable when form is invalid (default: true)\n disableWhileSubmitting: true, // Disable during submission (default: true)\n },\n nextButton: {\n disableWhenPageInvalid: true, // Disable when current page is invalid (default: true)\n disableWhileSubmitting: true, // Disable during submission (default: true)\n }\n }\n};\n```\n\n### SubmitButtonOptions\n\n| Option | Type | Default | Description |\n| ------------------------ | --------- | ------- | ------------------------------ |\n| `disableWhenInvalid` | `boolean` | `true` | Disable when form is invalid |\n| `disableWhileSubmitting` | `boolean` | `true` | Disable during form submission |\n\n### NextButtonOptions\n\n| Option | Type | Default | Description |\n| ------------------------ | --------- | ------- | -------------------------------------------- |\n| `disableWhenPageInvalid` | `boolean` | `true` | Disable when current page has invalid fields |\n| `disableWhileSubmitting` | `boolean` | `true` | Disable during form submission |\n\n## Custom Button Logic\n\nOverride form-level defaults on individual buttons using the `logic` array:\n\n```typescript\n{\n type: 'submit',\n key: 'submit',\n label: 'Submit',\n logic: [\n { type: 'disabled', condition: 'formInvalid' },\n { type: 'disabled', condition: 'formSubmitting' }\n ]\n}\n```\n\n**Note:** `type: 'submit'` is a UI-integration convenience type that pre-configures the button with `SubmitEvent`. Use `type: 'button'` with `event: SubmitEvent` for the core API.\n\n### FormStateCondition\n\nSpecial condition strings for button logic:\n\n| Condition | Description |\n| ------------------ | ------------------------------------------------------------ |\n| `'formInvalid'` | Evaluates to `true` when the form is invalid |\n| `'formSubmitting'` | Evaluates to `true` when form submission is in progress |\n| `'pageInvalid'` | Evaluates to `true` when the current page has invalid fields |\n\n### Logic Precedence\n\nButton disabled state is determined in this order:\n\n1. **Explicit `disabled: true`** - Always wins\n2. **Field-level `logic` array** - If present, overrides form-level defaults\n3. **Form-level options** - Default behavior from `options.submitButton` or `options.nextButton`\n\n### Advanced Logic Example\n\nCombine form state conditions with custom expressions:\n\n```typescript\n{\n type: 'submit',\n key: 'submit',\n label: 'Submit',\n logic: [\n // Disable when form is invalid\n { type: 'disabled', condition: 'formInvalid' },\n // Disable when submitting\n { type: 'disabled', condition: 'formSubmitting' },\n // Also disable if terms not accepted\n {\n type: 'disabled',\n condition: {\n type: 'fieldValue',\n fieldPath: 'acceptTerms',\n operator: 'equals',\n value: false,\n }\n }\n ]\n}\n```\n\n## Manual Submission\n\nIf you prefer manual control, use the `(submitted)` output instead:\n\n```typescript\n@Component({\n template: `<form [dynamic-form]=\"config\" (submitted)=\"onSubmit($event)\"></form>`,\n})\nexport class MyFormComponent {\n onSubmit(value: FormValue) {\n this.http.post('/api/submit', value).subscribe();\n }\n}\n```"
|
|
63
|
+
content: "Form submission in dynamic forms integrates with Angular Signal Forms' native `submit()` function, providing automatic button disabling, loading states, and server error handling.\n\n## Overview\n\nThe submission system provides:\n\n- Async form submission with automatic loading state\n- Server-side validation error handling\n- Automatic submit button disabling during submission\n- Configurable button disabled behavior\n- Custom disabled logic via `FormStateCondition`\n\n**Note**: The submission mechanism is **optional** - you can still handle submission manually via the `(submitted)` output if you prefer.\n\n## Basic Usage\n\nConfigure form submission with the `submission` property:\n\n```typescript\nimport { Component, inject } from '@angular/core';\nimport { HttpClient } from '@angular/common/http';\nimport { FormConfig, InferFormValue } from '@ng-forge/dynamic-forms';\n\nconst formFields = {\n fields: [\n { type: 'input', key: 'email', label: 'Email', value: '', required: true, email: true },\n { type: 'input', key: 'password', label: 'Password', value: '', required: true, props: { type: 'password' } },\n { type: 'submit', key: 'submit', label: 'Sign In' },\n ],\n} as const satisfies FormConfig;\n\ntype LoginForm = InferFormValue<typeof formFields.fields>;\n\n@Component({...})\nexport class LoginFormComponent {\n private http = inject(HttpClient);\n\n config = {\n ...formFields,\n submission: {\n action: (form: LoginForm) => this.http.post('/api/login', form),\n },\n };\n}\n```\n\n## SubmissionConfig\n\nThe `submission.action` function receives the typed form value and can return an Observable or Promise:\n\n| Return Value | Meaning |\n| ---------------------- | ------------------------------------------ |\n| Completes successfully | Successful submission |\n| `TreeValidationResult` | Server validation errors (single or array) |\n\n**Note:** `TreeValidationResult` from Angular Signal Forms supports both single errors and arrays. You can return a single error object or an array of errors.\n\nWhile the action is executing, the form is in a submitting state, enabling automatic button disabling and loading states.\n\n### Server Error Handling\n\nReturn validation errors to apply them to specific fields using `catchError`:\n\n```typescript\nimport { catchError, of } from 'rxjs';\n\nsubmission: {\n action: (form: FormValue) => this.http.post('/api/register', form).pipe(\n catchError((error) => {\n if (error.error?.code === 'EMAIL_EXISTS') {\n return of([{\n field: 'email',\n error: { kind: 'server', message: 'Email already exists' },\n }]);\n }\n throw error; // Re-throw unexpected errors\n }),\n ),\n}\n```\n\n## Button Disabled Behavior\n\nSubmit and next page buttons are automatically disabled based on form state. Configure defaults via `options`:\n\n```typescript\nconst config: FormConfig = {\n fields: [...],\n options: {\n submitButton: {\n disableWhenInvalid: true, // Disable when form is invalid (default: true)\n disableWhileSubmitting: true, // Disable during submission (default: true)\n },\n nextButton: {\n disableWhenPageInvalid: true, // Disable when current page is invalid (default: true)\n disableWhileSubmitting: true, // Disable during submission (default: true)\n }\n }\n};\n```\n\n### SubmitButtonOptions\n\n| Option | Type | Default | Description |\n| ------------------------ | --------- | ------- | ------------------------------ |\n| `disableWhenInvalid` | `boolean` | `true` | Disable when form is invalid |\n| `disableWhileSubmitting` | `boolean` | `true` | Disable during form submission |\n\n### NextButtonOptions\n\n| Option | Type | Default | Description |\n| ------------------------ | --------- | ------- | -------------------------------------------- |\n| `disableWhenPageInvalid` | `boolean` | `true` | Disable when current page has invalid fields |\n| `disableWhileSubmitting` | `boolean` | `true` | Disable during form submission |\n\n## Custom Button Logic\n\nOverride form-level defaults on individual buttons using the `logic` array:\n\n```typescript\n{\n type: 'submit',\n key: 'submit',\n label: 'Submit',\n logic: [\n { type: 'disabled', condition: 'formInvalid' },\n { type: 'disabled', condition: 'formSubmitting' }\n ]\n}\n```\n\n**Note:** `type: 'submit'` is a UI-integration convenience type that pre-configures the button with `SubmitEvent`. Use `type: 'button'` with `event: SubmitEvent` for the core API.\n\n### FormStateCondition\n\nSpecial condition strings for button logic:\n\n| Condition | Description |\n| ------------------ | ------------------------------------------------------------ |\n| `'formInvalid'` | Evaluates to `true` when the form is invalid |\n| `'formSubmitting'` | Evaluates to `true` when form submission is in progress |\n| `'pageInvalid'` | Evaluates to `true` when the current page has invalid fields |\n\n### Logic Precedence\n\nButton disabled state is determined in this order:\n\n1. **Explicit `disabled: true`** - Always wins\n2. **Field-level `logic` array** - If present, overrides form-level defaults\n3. **Form-level options** - Default behavior from `options.submitButton` or `options.nextButton`\n\n### Advanced Logic Example\n\nCombine form state conditions with custom expressions:\n\n```typescript\n{\n type: 'submit',\n key: 'submit',\n label: 'Submit',\n logic: [\n // Disable when form is invalid\n { type: 'disabled', condition: 'formInvalid' },\n // Disable when submitting\n { type: 'disabled', condition: 'formSubmitting' },\n // Also disable if terms not accepted\n {\n type: 'disabled',\n condition: {\n type: 'fieldValue',\n fieldPath: 'acceptTerms',\n operator: 'equals',\n value: false,\n }\n }\n ]\n}\n```\n\n## Value Exclusion\n\nBy default, field values are excluded from the `(submitted)` output when the field is hidden, disabled, or readonly. This prevents stale or irrelevant data from being submitted.\n\nValue exclusion supports a 3-tier configuration hierarchy: **Global > Form > Field**, where the most specific level wins. Internal form state and two-way binding are unaffected.\n\n```typescript\n// Disable hidden value exclusion for this form\nconst config: FormConfig = {\n fields: [...],\n options: {\n excludeValueIfHidden: false,\n }\n};\n```\n\nSee the **Value Exclusion** page under Recipes for full documentation on configuration tiers, per-field overrides, and migration instructions.\n\n## Manual Submission\n\nIf you prefer manual control, use the `(submitted)` output instead:\n\n```typescript\n@Component({\n template: `<form [dynamic-form]=\"config\" (submitted)=\"onSubmit($event)\"></form>`,\n})\nexport class MyFormComponent {\n onSubmit(value: FormValue) {\n this.http.post('/api/submit', value).subscribe();\n }\n}\n```"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "dynamic-behavior-value-derivation-basics-async-derivation",
|
|
67
|
+
title: "Dynamic Behavior Value Derivation Basics Async Derivation",
|
|
68
|
+
category: "dynamic-behavior",
|
|
69
|
+
content: "Derive field values from HTTP responses or custom async functions. Async derivations are ideal for server-driven computed values, address/zip lookups, exchange rates, and any value that requires a network call.\n\n## HTTP Derivations\n\nUse `source: 'http'` to derive a field value from an HTTP response. The request fires when declared dependencies change, with automatic debouncing and in-flight request cancellation.\n\n### Basic Example\n\n```typescript\n{\n key: 'exchangeRate',\n type: 'input',\n label: 'Exchange Rate',\n readonly: true,\n logic: [{\n type: 'derivation',\n source: 'http',\n http: {\n url: '/api/exchange-rate',\n method: 'GET',\n queryParams: {\n currency: 'formValue.currency',\n },\n },\n responseExpression: 'response.rate',\n dependsOn: ['currency'],\n }],\n}\n```\n\nWhen `currency` changes, a GET request is sent and `response.rate` becomes the new field value.\n\n### HttpRequestConfig\n\n| Property | Type | Required | Description |\n| ------------------------- | ------------------------- | -------- | --------------------------------------------------------------------------- |\n| `url` | `string` | Yes | Request URL. Use `:key` placeholders for path parameters |\n| `method` | `string` | No | HTTP method. Defaults to `'GET'` |\n| `params` | `Record<string, string>` | No | Path parameters. Values are expressions evaluated against the form context |\n| `queryParams` | `Record<string, string>` | No | Query parameters. Values are expressions evaluated against the form context |\n| `body` | `Record<string, unknown>` | No | Request body (for POST/PUT/PATCH) |\n| `evaluateBodyExpressions` | `boolean` | No | When `true`, top-level string values in `body` are treated as expressions |\n| `headers` | `Record<string, string>` | No | Request headers |\n\n### Path Parameters\n\nUse `:key` placeholders in the URL and provide values via `params`. Values are expressions evaluated against the form context, then `encodeURIComponent`-encoded:\n\n```typescript\nhttp: {\n url: '/api/users/:userId/orders/:orderId',\n params: {\n userId: 'formValue.userId',\n orderId: 'formValue.orderId',\n },\n}\n```\n\n### Query Params as Expressions\n\nValues in `queryParams` are evaluated as expressions, giving you dynamic request parameters built from form values:\n\n```typescript\nhttp: {\n url: '/api/shipping-estimate',\n queryParams: {\n fromZip: 'formValue.originZip',\n toZip: 'formValue.destinationZip',\n weight: 'formValue.packageWeight',\n },\n}\n```\n\n### POST with Dynamic Body\n\nFor `POST` requests, use `evaluateBodyExpressions: true` to build the body from form values:\n\n```typescript\nhttp: {\n url: '/api/calculate-tax',\n method: 'POST',\n body: {\n subtotal: 'formValue.subtotal',\n state: 'formValue.state',\n items: 'formValue.lineItems',\n },\n evaluateBodyExpressions: true,\n},\n```\n\nTop-level string values in `body` are evaluated as expressions. Nested objects are passed through as-is.\n\n### Extracting the Response Value\n\n`responseExpression` is evaluated with `{ response }` in scope and extracts the derived value:\n\n```typescript\nresponseExpression: 'response.rate'; // Simple property\nresponseExpression: 'response.data.price'; // Nested property\nresponseExpression: 'response.items[0].value'; // Array access\n```\n\n### Required Fields\n\n`source: 'http'` enforces two fields at the TypeScript level:\n\n- **`dependsOn: string[]`** \u2014 Explicit field dependencies. The request only re-fires when these fields change.\n- **`responseExpression: string`** \u2014 Expression to extract the derived value from the response body.\n\n### Controlling Request Timing\n\nUse `trigger` and `debounceMs` to control when requests fire:\n\n```typescript\n{\n type: 'derivation',\n source: 'http',\n http: {\n url: '/api/username-suggest',\n queryParams: { q: 'formValue.companyName' },\n },\n responseExpression: 'response.suggestion',\n dependsOn: ['companyName'],\n trigger: 'debounced',\n debounceMs: 400,\n}\n```\n\nThe request waits 400ms after the user stops typing before firing.\n\n### Conditional HTTP Derivation\n\nAdd a `condition` to only send the request when certain criteria are met:\n\n```typescript\n{\n key: 'suggestedPrice',\n type: 'input',\n readonly: true,\n logic: [{\n type: 'derivation',\n source: 'http',\n http: {\n url: '/api/price-suggest',\n queryParams: { productId: 'formValue.productId' },\n },\n responseExpression: 'response.suggestedPrice',\n dependsOn: ['productId'],\n condition: {\n type: 'fieldValue',\n fieldPath: 'productId',\n operator: 'notEquals',\n value: '',\n },\n }],\n}\n```\n\n## Async Function Derivations\n\nUse `source: 'asyncFunction'` when you need custom logic \u2014 Angular service injection, complex transformations, or any async computation not expressible as a plain HTTP config. Functions receive the full evaluation context and can return a `Promise` or `Observable`.\n\n### Basic Example\n\n```typescript\n// 1. Define and register the function in customFnConfig\nconst formConfig = {\n customFnConfig: {\n asyncDerivations: {\n lookupCity: async (context) => {\n const response = await fetch(`/api/geocode?zip=${context.formValue.zipCode}`);\n const data = await response.json();\n return data.city;\n },\n },\n },\n\n // 2. Reference it on the target field\n fields: [\n {\n key: 'city',\n type: 'input',\n label: 'City',\n readonly: true,\n logic: [\n {\n type: 'derivation',\n source: 'asyncFunction',\n asyncFunctionName: 'lookupCity',\n dependsOn: ['zipCode'],\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\n### Using RxJS Observables\n\nFunctions can return an `Observable` instead of a `Promise`, which is useful when using Angular services:\n\n```typescript\ncustomFnConfig: {\n asyncDerivations: {\n fetchAddress: (context) => {\n return inject(AddressService)\n .lookup(context.formValue.zipCode as string)\n .pipe(map(result => result.formattedAddress));\n },\n },\n},\n```\n\n### Available Context\n\nAsync derivation functions receive the full `EvaluationContext`:\n\n| Variable | Description |\n| ----------------------- | ------------------------------------------------ |\n| `context.formValue` | All current form field values |\n| `context.fieldValue` | The current value of the target field |\n| `context.externalData` | External data passed to the form (if configured) |\n| `context.rootFormValue` | Entire form value when inside array fields |\n\n### Conditional Async Derivation\n\nCombine with `condition` to only run the function when needed:\n\n```typescript\n{\n key: 'city',\n type: 'input',\n label: 'City',\n readonly: true,\n logic: [{\n type: 'derivation',\n source: 'asyncFunction',\n asyncFunctionName: 'lookupCity',\n dependsOn: ['zipCode'],\n condition: {\n type: 'fieldValue',\n fieldPath: 'enableLookup',\n operator: 'equals',\n value: true,\n },\n }],\n}\n```\n\nThe async function is only called when `enableLookup === true`.\n\n### Required Fields\n\n`source: 'asyncFunction'` requires:\n\n- **`asyncFunctionName: string`** \u2014 Name of the function registered in `customFnConfig.asyncDerivations`.\n- **`dependsOn: string[]`** \u2014 Explicit field dependencies. Without this, the function would re-run on every form change.\n\n## Stop On User Override\n\n`stopOnUserOverride` turns a derivation into a \"smart default\" \u2014 the field is auto-filled initially, but derivation stops once the user manually edits it.\n\n### Basic Example\n\n```typescript\n{\n key: 'displayName',\n type: 'input',\n label: 'Display Name',\n logic: [{\n type: 'derivation',\n expression: 'formValue.firstName + \" \" + formValue.lastName',\n stopOnUserOverride: true,\n }],\n}\n```\n\nThe display name is auto-filled from first and last name. Once the user changes it directly, auto-fill stops.\n\n### Re-Engaging on Dependency Change\n\nUse `reEngageOnDependencyChange: true` to clear the user override when a dependency changes. The derivation respects user edits, but re-engages if the underlying data changes:\n\n```typescript\n{\n key: 'phonePrefix',\n type: 'input',\n label: 'Phone Prefix',\n logic: [\n {\n type: 'derivation',\n value: '+1',\n condition: { type: 'fieldValue', fieldPath: 'country', operator: 'equals', value: 'US' },\n stopOnUserOverride: true,\n reEngageOnDependencyChange: true,\n dependsOn: ['country'],\n },\n {\n type: 'derivation',\n value: '+44',\n condition: { type: 'fieldValue', fieldPath: 'country', operator: 'equals', value: 'UK' },\n stopOnUserOverride: true,\n reEngageOnDependencyChange: true,\n dependsOn: ['country'],\n },\n ],\n}\n```\n\nWhen the user changes the country, the phone prefix is auto-filled again \u2014 overriding any previous manual edit.\n\n**Without** `reEngageOnDependencyChange`: once the user edits the field, the derivation never runs again for that field instance.\n\n**With** `reEngageOnDependencyChange`: the user override is cleared when a declared dependency changes, and the derivation re-fires.\n\n### Works with All Sources\n\n`stopOnUserOverride` and `reEngageOnDependencyChange` work across all derivation sources:\n\n```typescript\n// HTTP derivation with user override\n{\n type: 'derivation',\n source: 'http',\n http: {\n url: '/api/suggest-price',\n queryParams: { category: 'formValue.category' },\n },\n responseExpression: 'response.suggestedPrice',\n dependsOn: ['category'],\n stopOnUserOverride: true,\n reEngageOnDependencyChange: true,\n}\n\n// Async function derivation with user override\n{\n type: 'derivation',\n source: 'asyncFunction',\n asyncFunctionName: 'fetchDefaultTitle',\n dependsOn: ['template'],\n stopOnUserOverride: true,\n}\n```\n\n## DerivationLogicConfig Reference (Async)\n\n### HTTP Derivation Fields\n\n```typescript\n{\n type: 'derivation';\n source: 'http'; // Required \u2014 identifies HTTP mode\n\n http: HttpRequestConfig; // Required \u2014 request configuration\n dependsOn: string[]; // Required \u2014 explicit field dependencies\n responseExpression: string; // Required \u2014 expression to extract value from response\n\n // Shared optional fields\n trigger?: 'onChange' | 'debounced'; // Default: 'onChange'\n debounceMs?: number; // Default: 500 (when trigger: 'debounced')\n condition?: ConditionalExpression | boolean;\n stopOnUserOverride?: boolean;\n reEngageOnDependencyChange?: boolean;\n debugName?: string;\n}\n```\n\n### Async Function Derivation Fields\n\n```typescript\n{\n type: 'derivation';\n source: 'asyncFunction'; // Required \u2014 identifies async function mode\n\n asyncFunctionName: string; // Required \u2014 name in customFnConfig.asyncDerivations\n dependsOn: string[]; // Required \u2014 explicit field dependencies\n\n // Shared optional fields\n trigger?: 'onChange' | 'debounced';\n debounceMs?: number;\n condition?: ConditionalExpression | boolean;\n stopOnUserOverride?: boolean;\n reEngageOnDependencyChange?: boolean;\n debugName?: string;\n}\n```\n\n## Related\n\n- **[Values](./)** \u2014 Expression, static value, and function-based derivations\n- **[Properties](./property-derivation)** \u2014 Derive component properties from form values\n- **[HTTP Conditions](../../conditional-logic/overview/)** \u2014 HTTP-driven field visibility and state\n- **[Custom Validators](../../../validation/custom-validators/)** \u2014 Async and HTTP validation"
|
|
58
70
|
},
|
|
59
71
|
{
|
|
60
72
|
id: "dynamic-behavior-value-derivation-basics",
|
|
61
73
|
title: "Dynamic Behavior Value Derivation Basics",
|
|
62
74
|
category: "dynamic-behavior",
|
|
63
|
-
content: "Automatically compute and set field values based on other form values. Derivations enable calculated fields, auto-fill patterns, and value transformations.\n\n## Quick Start\n\nDerivations are defined ON the field that receives the computed value (self-targeting).\n\n### Shorthand Syntax (Preferred)\n\n```typescript\n{\n key: 'total',\n type: 'input',\n label: 'Total',\n readonly: true,\n derivation: 'formValue.quantity * formValue.unitPrice',\n}\n```\n\n### Logic Block Syntax\n\n```typescript\n{\n key: 'total',\n type: 'input',\n label: 'Total',\n readonly: true,\n logic: [{\n type: 'derivation',\n expression: 'formValue.quantity * formValue.unitPrice',\n }],\n}\n```\n\nWhen `quantity` or `unitPrice` changes, `total` is automatically recalculated.\n\n## Derivation Types\n\n### Expression-Based\n\nUse JavaScript expressions with access to `formValue`:\n\n```typescript\n{\n key: 'fullName',\n type: 'input',\n label: 'Full Name',\n readonly: true,\n derivation: '(formValue.firstName || \"\") + \" \" + (formValue.lastName || \"\")',\n}\n```\n\n**Available variables:**\n\n- `formValue` - Object containing all form field values\n- `externalData` - External application state (when configured in FormConfig)\n\n### Static Value\n\nSet a constant value when a condition is met:\n\n```typescript\n{\n key: 'phonePrefix',\n type: 'input',\n label: 'Phone Prefix',\n readonly: true,\n logic: [{\n type: 'derivation',\n value: '+1',\n condition: {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'USA',\n },\n }],\n}\n```\n\n### Custom Function\n\nUse a registered function for complex logic:\n\n```typescript\n// In form config\ncustomFnConfig: {\n derivations: {\n calculateTax: (ctx) => ctx.formValue.subtotal * getTaxRate(ctx.formValue.state),\n },\n},\nfields: [\n {\n key: 'tax',\n type: 'input',\n readonly: true,\n logic: [{\n type: 'derivation',\n functionName: 'calculateTax',\n dependsOn: ['subtotal', 'state'],\n }],\n },\n],\n```\n\n## Trigger Timing\n\nControl when derivations evaluate:\n\n| Trigger | Description | Use Case |\n| ----------- | ------------------------------------- | ------------------------------------- |\n| `onChange` | Immediately on value change (default) | Computed totals, conditional prefixes |\n| `debounced` | After value stabilizes | Self-transforms, format masking |\n\n### Debounced Derivations\n\nUse `trigger: 'debounced'` for self-transforming fields to avoid interrupting the user while typing:\n\n```typescript\n{\n key: 'email',\n type: 'input',\n label: 'Email',\n logic: [{\n type: 'derivation',\n expression: 'formValue.email.toLowerCase()',\n trigger: 'debounced',\n debounceMs: 500, // optional, defaults to 500\n }],\n}\n```\n\nThe transformation applies after the user stops typing for 500ms.\n\n## Conditional Derivations\n\nOnly apply derivations when conditions are met:\n\n```typescript\n{\n key: 'currency',\n type: 'input',\n label: 'Currency',\n readonly: true,\n logic: [\n {\n type: 'derivation',\n value: 'USD',\n condition: {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'USA',\n },\n },\n {\n type: 'derivation',\n value: 'EUR',\n condition: {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'Germany',\n },\n },\n ],\n}\n```\n\nMultiple derivations on the same field are evaluated in order.\n\n## Dependencies\n\n### Automatic Detection\n\nFor expressions, dependencies are automatically extracted:\n\n```typescript\n{\n key: 'total',\n type: 'input',\n label: 'Total',\n readonly: true,\n derivation: 'formValue.quantity * formValue.unitPrice',\n // Automatically depends on: quantity, unitPrice\n}\n```\n\n### Explicit Dependencies\n\nFor custom functions, specify dependencies explicitly:\n\n```typescript\n{\n key: 'discount',\n type: 'input',\n label: 'Discount',\n readonly: true,\n logic: [{\n type: 'derivation',\n functionName: 'calculateDiscount',\n dependsOn: ['total', 'memberLevel'],\n }],\n}\n```\n\nWithout `dependsOn`, custom functions re-evaluate on any form change.\n\n## Complete Example\n\n```typescript\nconst orderForm = {\n fields: [\n {\n key: 'quantity',\n type: 'input',\n value: 1,\n label: 'Quantity',\n props: { type: 'number' },\n },\n {\n key: 'unitPrice',\n type: 'input',\n value: 10,\n label: 'Unit Price',\n props: { type: 'number' },\n },\n {\n key: 'subtotal',\n type: 'input',\n value: 0,\n label: 'Subtotal',\n readonly: true,\n derivation: 'formValue.quantity * formValue.unitPrice',\n },\n {\n key: 'tax',\n type: 'input',\n value: 0,\n label: 'Tax (10%)',\n readonly: true,\n derivation: 'formValue.subtotal * 0.1',\n },\n {\n key: 'total',\n type: 'input',\n value: 0,\n label: 'Total',\n readonly: true,\n derivation: 'formValue.subtotal + formValue.tax',\n },\n ],\n} as const satisfies FormConfig;\n```\n\n## Array Field Derivations\n\nInside arrays, `formValue` is scoped to the current array item:\n\n```typescript\n{\n key: 'lineItems',\n type: 'array',\n fields: [\n {\n key: 'itemRow',\n type: 'row',\n fields: [\n { key: 'quantity', type: 'input', label: 'Qty', value: 1 },\n { key: 'unitPrice', type: 'input', label: 'Price', value: 0 },\n {\n key: 'lineTotal',\n type: 'input',\n label: 'Total',\n value: 0,\n readonly: true,\n // formValue is scoped to the current array item\n derivation: 'formValue.quantity * formValue.unitPrice',\n },\n ],\n },\n ],\n}\n```\n\n### Accessing Root Form Value\n\nInside array items, `formValue` refers to the current array item. Use `rootFormValue` to access the entire form:\n\n```typescript\n{\n key: 'lineTotal',\n type: 'input',\n label: 'Total',\n readonly: true,\n // formValue = current array item { quantity, unitPrice }\n // rootFormValue = entire form { lineItems, discount, ... }\n derivation: 'formValue.quantity * formValue.unitPrice * (1 - rootFormValue.discount / 100)',\n}\n```\n\n## Debugging Derivations\n\nEnable derivation logging to troubleshoot issues:\n\n### Configuration\n\nConfigure derivation logging via `withLoggerConfig`:\n\n```typescript\n// In your providers\nprovideDynamicForm(...withMaterialFields(), withLoggerConfig({ derivations: 'verbose' }));\n\n// Or just summary level\nprovideDynamicForm(...withMaterialFields(), withLoggerConfig({ derivations: 'summary' }));\n```\n\n### Log Levels\n\n| Level | Output |\n| --------- | --------------------------------- |\n| `none` | No logging (default) |\n| `summary` | Cycle completion with counts |\n| `verbose` | Individual derivation evaluations |\n\n### Using debugName\n\nAdd names to derivations for easier identification in logs:\n\n```typescript\n{\n key: 'lineTotal',\n type: 'input',\n label: 'Total',\n readonly: true,\n logic: [{\n type: 'derivation',\n debugName: 'Calculate line total',\n expression: 'formValue.quantity * formValue.unitPrice',\n }],\n}\n```\n\n**Console output (verbose mode):**\n\n```\nDerivation - Starting cycle (onChange) with 5 derivation(s)\nDerivation - Iteration 1\nDerivation - Applied \"Calculate line total\" { field: 'lineTotal', newValue: 150 }\nDerivation - Skipped: phonePrefix (condition not met)\nDerivation - Cycle complete (onChange) { applied: 1, skipped: 4, errors: 0, iterations: 1 }\n```\n\n## Bidirectional Derivations\n\nCreate two-way bindings between fields:\n\n```typescript\n// Celsius to Fahrenheit\n{\n key: 'fahrenheit',\n type: 'input',\n value: 32,\n derivation: 'formValue.celsius * 9 / 5 + 32',\n}\n\n// Fahrenheit to Celsius\n{\n key: 'celsius',\n type: 'input',\n value: 0,\n derivation: '(formValue.fahrenheit - 32) * 5 / 9',\n}\n```\n\n### Cycle Detection\n\nThe system automatically detects derivation cycles and warns during development:\n\n```\nBidirectional derivation detected: celsius <-> fahrenheit\nBidirectional derivations stabilize via equality check.\n```\n\n### Floating-Point Precision\n\nBidirectional derivations stabilize when the computed value equals the current value. For floating-point operations, consider:\n\n1. **Rounding in expressions:**\n\n ```typescript\n derivation: 'Math.round(formValue.usd * exchangeRate * 100) / 100';\n ```\n\n2. **Using integers:** Store cents instead of dollars\n3. **One-way derivation:** If bidirectional isn't required\n\n## Derivation Processing\n\n### Evaluation Order\n\nDerivations are topologically sorted based on dependencies:\n\n```\nquantity \u2500\u2510\n \u251C\u2500\u2500 subtotal \u2500\u2510\nunitPrice \u2500\u2518 \u251C\u2500\u2500 total\n \u2502\n tax \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\nThis ensures `subtotal` is computed before `total`.\n\n### Iteration Limits\n\nTo prevent infinite loops, derivations are limited to 10 iterations per cycle. If exceeded:\n\n```\nDerivation - Max iterations reached (onChange).\nThis may indicate a loop in derivation logic.\n```\n\n## DerivationLogicConfig Interface\n\n```typescript\ninterface DerivationLogicConfig {\n /** Logic type identifier */\n type: 'derivation';\n\n /** Optional name for debugging */\n debugName?: string;\n\n /** When to evaluate: 'onChange' (default) or 'debounced' */\n trigger?: 'onChange' | 'debounced';\n\n /** Debounce duration in ms (default: 500) */\n debounceMs?: number;\n\n /** Static value to set */\n value?: unknown;\n\n /** JavaScript expression (has access to formValue) */\n expression?: string;\n\n /** Name of registered custom function */\n functionName?: string;\n\n /** Explicit field dependencies */\n dependsOn?: string[];\n\n /** Condition for when derivation applies */\n condition?: ConditionalExpression | boolean;\n}\n```\n\n## External Data in Derivations\n\nUse external application state in derivation expressions:\n\n```typescript\nconst config = {\n externalData: {\n discountRate: computed(() => pricingService.currentDiscount()),\n },\n fields: [\n {\n key: 'discountedPrice',\n type: 'input',\n label: 'Discounted Price',\n readonly: true,\n derivation: 'formValue.price * (1 - externalData.discountRate)',\n },\n ],\n} as const satisfies FormConfig;\n```\n\nExternal data values are reactively tracked - when signals change, derivations are re-evaluated.\n\n## Related\n\n- **[Conditional Logic](../conditional-logic/overview/)** - Control field visibility and state\n- **[Array Fields](../../prebuilt/form-arrays/)** - Working with array fields\n- **[Examples](/docs/examples)** - Real-world form patterns"
|
|
75
|
+
content: "Automatically compute and set field values based on other form values. Derivations enable calculated fields, auto-fill patterns, and value transformations.\n\n## Quick Start\n\nDerivations are defined ON the field that receives the computed value (self-targeting).\n\n### Shorthand Syntax (Preferred)\n\n```typescript\n{\n key: 'total',\n type: 'input',\n label: 'Total',\n readonly: true,\n derivation: 'formValue.quantity * formValue.unitPrice',\n}\n```\n\n### Logic Block Syntax\n\n```typescript\n{\n key: 'total',\n type: 'input',\n label: 'Total',\n readonly: true,\n logic: [{\n type: 'derivation',\n expression: 'formValue.quantity * formValue.unitPrice',\n }],\n}\n```\n\nWhen `quantity` or `unitPrice` changes, `total` is automatically recalculated.\n\n## Derivation Types\n\n### Expression-Based\n\nUse JavaScript expressions with access to `formValue`:\n\n```typescript\n{\n key: 'fullName',\n type: 'input',\n label: 'Full Name',\n readonly: true,\n derivation: '(formValue.firstName || \"\") + \" \" + (formValue.lastName || \"\")',\n}\n```\n\n**Available variables:**\n\n- `formValue` - Object containing all form field values\n- `externalData` - External application state (when configured in FormConfig)\n\n### Static Value\n\nSet a constant value when a condition is met:\n\n```typescript\n{\n key: 'phonePrefix',\n type: 'input',\n label: 'Phone Prefix',\n readonly: true,\n logic: [{\n type: 'derivation',\n value: '+1',\n condition: {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'USA',\n },\n }],\n}\n```\n\n### Custom Function\n\nUse a registered function for complex logic:\n\n```typescript\n// In form config\ncustomFnConfig: {\n derivations: {\n calculateTax: (ctx) => ctx.formValue.subtotal * getTaxRate(ctx.formValue.state),\n },\n},\nfields: [\n {\n key: 'tax',\n type: 'input',\n readonly: true,\n logic: [{\n type: 'derivation',\n functionName: 'calculateTax',\n dependsOn: ['subtotal', 'state'],\n }],\n },\n],\n```\n\n## Trigger Timing\n\nControl when derivations evaluate:\n\n| Trigger | Description | Use Case |\n| ----------- | ------------------------------------- | ------------------------------------- |\n| `onChange` | Immediately on value change (default) | Computed totals, conditional prefixes |\n| `debounced` | After value stabilizes | Self-transforms, format masking |\n\n### Debounced Derivations\n\nUse `trigger: 'debounced'` for self-transforming fields to avoid interrupting the user while typing:\n\n```typescript\n{\n key: 'email',\n type: 'input',\n label: 'Email',\n logic: [{\n type: 'derivation',\n expression: 'formValue.email.toLowerCase()',\n trigger: 'debounced',\n debounceMs: 500, // optional, defaults to 500\n }],\n}\n```\n\nThe transformation applies after the user stops typing for 500ms.\n\n## Conditional Derivations\n\nOnly apply derivations when conditions are met:\n\n```typescript\n{\n key: 'currency',\n type: 'input',\n label: 'Currency',\n readonly: true,\n logic: [\n {\n type: 'derivation',\n value: 'USD',\n condition: {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'USA',\n },\n },\n {\n type: 'derivation',\n value: 'EUR',\n condition: {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'Germany',\n },\n },\n ],\n}\n```\n\nMultiple derivations on the same field are evaluated in order.\n\n## Dependencies\n\n### Automatic Detection\n\nFor expressions, dependencies are automatically extracted:\n\n```typescript\n{\n key: 'total',\n type: 'input',\n label: 'Total',\n readonly: true,\n derivation: 'formValue.quantity * formValue.unitPrice',\n // Automatically depends on: quantity, unitPrice\n}\n```\n\n### Explicit Dependencies\n\nFor custom functions, specify dependencies explicitly:\n\n```typescript\n{\n key: 'discount',\n type: 'input',\n label: 'Discount',\n readonly: true,\n logic: [{\n type: 'derivation',\n functionName: 'calculateDiscount',\n dependsOn: ['total', 'memberLevel'],\n }],\n}\n```\n\nWithout `dependsOn`, custom functions re-evaluate on any form change.\n\n## Complete Example\n\n```typescript\nconst orderForm = {\n fields: [\n {\n key: 'quantity',\n type: 'input',\n value: 1,\n label: 'Quantity',\n props: { type: 'number' },\n },\n {\n key: 'unitPrice',\n type: 'input',\n value: 10,\n label: 'Unit Price',\n props: { type: 'number' },\n },\n {\n key: 'subtotal',\n type: 'input',\n value: 0,\n label: 'Subtotal',\n readonly: true,\n derivation: 'formValue.quantity * formValue.unitPrice',\n },\n {\n key: 'tax',\n type: 'input',\n value: 0,\n label: 'Tax (10%)',\n readonly: true,\n derivation: 'formValue.subtotal * 0.1',\n },\n {\n key: 'total',\n type: 'input',\n value: 0,\n label: 'Total',\n readonly: true,\n derivation: 'formValue.subtotal + formValue.tax',\n },\n ],\n} as const satisfies FormConfig;\n```\n\n## Array Field Derivations\n\nInside arrays, `formValue` is scoped to the current array item:\n\n```typescript\n{\n key: 'lineItems',\n type: 'array',\n fields: [\n {\n key: 'itemRow',\n type: 'row',\n fields: [\n { key: 'quantity', type: 'input', label: 'Qty', value: 1 },\n { key: 'unitPrice', type: 'input', label: 'Price', value: 0 },\n {\n key: 'lineTotal',\n type: 'input',\n label: 'Total',\n value: 0,\n readonly: true,\n // formValue is scoped to the current array item\n derivation: 'formValue.quantity * formValue.unitPrice',\n },\n ],\n },\n ],\n}\n```\n\n### Accessing Root Form Value\n\nInside array items, `formValue` refers to the current array item. Use `rootFormValue` to access the entire form:\n\n```typescript\n{\n key: 'lineTotal',\n type: 'input',\n label: 'Total',\n readonly: true,\n // formValue = current array item { quantity, unitPrice }\n // rootFormValue = entire form { lineItems, discount, ... }\n derivation: 'formValue.quantity * formValue.unitPrice * (1 - rootFormValue.discount / 100)',\n}\n```\n\n## Debugging Derivations\n\nEnable derivation logging to troubleshoot issues:\n\n### Configuration\n\nConfigure derivation logging via `withLoggerConfig`:\n\n```typescript\n// In your providers\nprovideDynamicForm(...withMaterialFields(), withLoggerConfig({ derivations: 'verbose' }));\n\n// Or just summary level\nprovideDynamicForm(...withMaterialFields(), withLoggerConfig({ derivations: 'summary' }));\n```\n\n### Log Levels\n\n| Level | Output |\n| --------- | --------------------------------- |\n| `none` | No logging (default) |\n| `summary` | Cycle completion with counts |\n| `verbose` | Individual derivation evaluations |\n\n### Using debugName\n\nAdd names to derivations for easier identification in logs:\n\n```typescript\n{\n key: 'lineTotal',\n type: 'input',\n label: 'Total',\n readonly: true,\n logic: [{\n type: 'derivation',\n debugName: 'Calculate line total',\n expression: 'formValue.quantity * formValue.unitPrice',\n }],\n}\n```\n\n**Console output (verbose mode):**\n\n```\nDerivation - Starting cycle (onChange) with 5 derivation(s)\nDerivation - Iteration 1\nDerivation - Applied \"Calculate line total\" { field: 'lineTotal', newValue: 150 }\nDerivation - Skipped: phonePrefix (condition not met)\nDerivation - Cycle complete (onChange) { applied: 1, skipped: 4, errors: 0, iterations: 1 }\n```\n\n## Bidirectional Derivations\n\nCreate two-way bindings between fields:\n\n```typescript\n// Celsius to Fahrenheit\n{\n key: 'fahrenheit',\n type: 'input',\n value: 32,\n derivation: 'formValue.celsius * 9 / 5 + 32',\n}\n\n// Fahrenheit to Celsius\n{\n key: 'celsius',\n type: 'input',\n value: 0,\n derivation: '(formValue.fahrenheit - 32) * 5 / 9',\n}\n```\n\n### Cycle Detection\n\nThe system automatically detects derivation cycles and warns during development:\n\n```\nBidirectional derivation detected: celsius <-> fahrenheit\nBidirectional derivations stabilize via equality check.\n```\n\n### Floating-Point Precision\n\nBidirectional derivations stabilize when the computed value equals the current value. For floating-point operations, consider:\n\n1. **Rounding in expressions:**\n\n ```typescript\n derivation: 'Math.round(formValue.usd * exchangeRate * 100) / 100';\n ```\n\n2. **Using integers:** Store cents instead of dollars\n3. **One-way derivation:** If bidirectional isn't required\n\n## Derivation Processing\n\n### Evaluation Order\n\nDerivations are topologically sorted based on dependencies:\n\n```\nquantity \u2500\u2510\n \u251C\u2500\u2500 subtotal \u2500\u2510\nunitPrice \u2500\u2518 \u251C\u2500\u2500 total\n \u2502\n tax \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\nThis ensures `subtotal` is computed before `total`.\n\n### Iteration Limits\n\nTo prevent infinite loops, derivations are limited to 10 iterations per cycle. If exceeded:\n\n```\nDerivation - Max iterations reached (onChange).\nThis may indicate a loop in derivation logic.\n```\n\n## DerivationLogicConfig Interface\n\n```typescript\ninterface DerivationLogicConfig {\n /** Logic type identifier */\n type: 'derivation';\n\n /** Optional name for debugging */\n debugName?: string;\n\n /** When to evaluate: 'onChange' (default) or 'debounced' */\n trigger?: 'onChange' | 'debounced';\n\n /** Debounce duration in ms (default: 500) */\n debounceMs?: number;\n\n /** Static value to set */\n value?: unknown;\n\n /** JavaScript expression (has access to formValue) */\n expression?: string;\n\n /** Name of registered custom function */\n functionName?: string;\n\n /** Explicit field dependencies */\n dependsOn?: string[];\n\n /** Condition for when derivation applies */\n condition?: ConditionalExpression | boolean;\n}\n```\n\n## External Data in Derivations\n\nUse external application state in derivation expressions:\n\n```typescript\nconst config = {\n externalData: {\n discountRate: computed(() => pricingService.currentDiscount()),\n },\n fields: [\n {\n key: 'discountedPrice',\n type: 'input',\n label: 'Discounted Price',\n readonly: true,\n derivation: 'formValue.price * (1 - externalData.discountRate)',\n },\n ],\n} as const satisfies FormConfig;\n```\n\nExternal data values are reactively tracked - when signals change, derivations are re-evaluated.\n\n## Related\n\n- **Property Derivation** (see tab above) \u2014 Derive component properties (minDate, options, label) from form values\n- **Async Derivation** (see tab above) \u2014 HTTP and async function derivations, stopOnUserOverride\n- **[Conditional Logic](../conditional-logic/overview/)** \u2014 Control field visibility and state\n- **[Array Fields](../../prebuilt/form-arrays/simplified/)** \u2014 Working with array fields\n- **[Examples](/examples)** \u2014 Real-world form patterns"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "dynamic-behavior-value-derivation-basics-property-derivation",
|
|
79
|
+
title: "Dynamic Behavior Value Derivation Basics Property Derivation",
|
|
80
|
+
category: "dynamic-behavior",
|
|
81
|
+
content: "Reactively derive field component properties (like `minDate`, `options`, `label`, `placeholder`) based on form values. While value derivations set a field's form value, property derivations set **component input properties**.\n\n## Quick Start\n\nProperty derivations use `type: 'derivation'` with `targetProperty` in the `logic` array and are self-targeting: the logic is placed on the field whose property should be derived.\n\n```typescript\n{\n key: 'endDate',\n type: 'datepicker',\n label: 'End Date',\n logic: [{\n type: 'derivation',\n targetProperty: 'minDate',\n expression: 'formValue.startDate',\n }]\n}\n```\n\nWhen `startDate` changes, the `minDate` property on `endDate`'s datepicker component is automatically updated.\n\n> **Migration note:** The previous `type: 'propertyDerivation'` syntax is deprecated but still works.\n> Use `type: 'derivation'` with `targetProperty` instead. Similarly, register functions via\n> `customFnConfig.derivations` instead of `customFnConfig.propertyDerivations`.\n\n## Derivation Sources\n\nProperty derivations support three mutually exclusive ways to compute a value.\n\n### Expression-Based\n\nUse JavaScript expressions with access to `formValue`:\n\n```typescript\n{\n key: 'endDate',\n type: 'datepicker',\n label: 'End Date',\n logic: [{\n type: 'derivation',\n targetProperty: 'minDate',\n expression: 'formValue.startDate',\n }]\n}\n```\n\n**Available variables:**\n\n- `formValue` - Object containing all form field values\n- `externalData` - External application state (when configured in FormConfig)\n\n### Static Value\n\nSet a constant property value when a condition is met:\n\n```typescript\n{\n key: 'phone',\n type: 'input',\n label: 'Phone',\n logic: [{\n type: 'derivation',\n targetProperty: 'label',\n value: 'Mobile Phone',\n condition: {\n type: 'fieldValue',\n fieldPath: 'contactType',\n operator: 'equals',\n value: 'mobile',\n },\n }]\n}\n```\n\n### Custom Function\n\nUse a registered function for complex logic:\n\n```typescript\n// In form config\ncustomFnConfig: {\n derivations: {\n getCitiesForCountry: (ctx) => {\n const cities: Record<string, { label: string; value: string }[]> = {\n 'US': [{ label: 'New York', value: 'nyc' }, { label: 'LA', value: 'la' }],\n 'DE': [{ label: 'Berlin', value: 'berlin' }],\n };\n return cities[ctx.formValue.country as string] ?? [];\n },\n },\n},\nfields: [\n {\n key: 'city',\n type: 'select',\n label: 'City',\n options: [],\n logic: [{\n type: 'derivation',\n targetProperty: 'options',\n functionName: 'getCitiesForCountry',\n dependsOn: ['country'],\n }],\n },\n],\n```\n\n## Target Properties\n\n### Simple Properties\n\nSet any direct component input property:\n\n```typescript\ntargetProperty: 'minDate'; // Date constraint\ntargetProperty: 'maxDate'; // Date constraint\ntargetProperty: 'options'; // Select/radio options\ntargetProperty: 'label'; // Field label\ntargetProperty: 'placeholder'; // Input placeholder\ntargetProperty: 'hint'; // Hint text\ntargetProperty: 'rows'; // Textarea rows\n```\n\n### Nested Properties (Dot Notation)\n\nSet properties nested one level deep using dot notation:\n\n```typescript\ntargetProperty: 'props.appearance'; // Material appearance\ntargetProperty: 'props.color'; // Material color\ntargetProperty: 'meta.autocomplete'; // Custom metadata\n```\n\n> **Note:** Maximum nesting depth is 2 levels (one dot). Deeper paths like `props.nested.deep` are not supported.\n\n## Trigger Timing\n\nControl when property derivations evaluate:\n\n| Trigger | Description | Use Case |\n| ----------- | ------------------------------------- | --------------------------------- |\n| `onChange` | Immediately on value change (default) | Date constraints, dynamic options |\n| `debounced` | After value stabilizes | Expensive lookups, search queries |\n\n### Debounced Property Derivations\n\nUse `trigger: 'debounced'` for expensive operations:\n\n```typescript\n{\n key: 'productSearch',\n type: 'select',\n label: 'Product',\n options: [],\n logic: [{\n type: 'derivation',\n targetProperty: 'options',\n functionName: 'searchProducts',\n trigger: 'debounced',\n debounceMs: 300,\n dependsOn: ['searchQuery'],\n }]\n}\n```\n\n## Conditional Property Derivations\n\nOnly apply property derivations when conditions are met:\n\n```typescript\n{\n key: 'email',\n type: 'input',\n label: 'Email',\n logic: [\n {\n type: 'derivation',\n targetProperty: 'label',\n value: 'Work Email',\n condition: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n },\n {\n type: 'derivation',\n targetProperty: 'label',\n value: 'Personal Email',\n condition: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'personal',\n },\n },\n ],\n}\n```\n\n## Dependencies\n\n### Automatic Detection\n\nFor expressions, dependencies are automatically extracted:\n\n```typescript\n{\n key: 'endDate',\n type: 'datepicker',\n label: 'End Date',\n logic: [{\n type: 'derivation',\n targetProperty: 'minDate',\n expression: 'formValue.startDate',\n // Automatically depends on: startDate\n }]\n}\n```\n\n### Explicit Dependencies\n\nFor custom functions, specify dependencies explicitly:\n\n```typescript\n{\n key: 'city',\n type: 'select',\n label: 'City',\n options: [],\n logic: [{\n type: 'derivation',\n targetProperty: 'options',\n functionName: 'getCitiesForCountry',\n dependsOn: ['country'],\n }]\n}\n```\n\nWithout `dependsOn`, custom functions re-evaluate on **any** form value change.\n\n## Array Field Property Derivations\n\nInside arrays, `formValue` is scoped to the current array item:\n\n```typescript\n{\n key: 'lineItems',\n type: 'array',\n fields: [\n {\n key: 'item',\n type: 'group',\n fields: [\n { key: 'startDate', type: 'datepicker', label: 'Start' },\n {\n key: 'endDate',\n type: 'datepicker',\n label: 'End',\n logic: [{\n type: 'derivation',\n targetProperty: 'minDate',\n // formValue is scoped to the current array item\n expression: 'formValue.startDate',\n }]\n },\n ],\n },\n ],\n}\n```\n\nEach array item independently derives its own `endDate.minDate` from its own `startDate`.\n\n## Complete Example\n\n```typescript\nconst travelForm = {\n customFnConfig: {\n derivations: {\n getCitiesForCountry: (ctx) => {\n const cities: Record<string, { label: string; value: string }[]> = {\n US: [\n { label: 'New York', value: 'nyc' },\n { label: 'Los Angeles', value: 'la' },\n { label: 'Chicago', value: 'chi' },\n ],\n DE: [\n { label: 'Berlin', value: 'berlin' },\n { label: 'Munich', value: 'munich' },\n ],\n };\n return cities[ctx.formValue.country as string] ?? [];\n },\n },\n },\n fields: [\n {\n key: 'country',\n type: 'select',\n label: 'Country',\n required: true,\n options: [\n { label: 'USA', value: 'US' },\n { label: 'Germany', value: 'DE' },\n ],\n },\n {\n key: 'city',\n type: 'select',\n label: 'City',\n required: true,\n options: [],\n logic: [\n {\n type: 'derivation',\n targetProperty: 'options',\n functionName: 'getCitiesForCountry',\n dependsOn: ['country'],\n },\n ],\n },\n { key: 'startDate', type: 'datepicker', label: 'Travel Start', required: true },\n {\n key: 'endDate',\n type: 'datepicker',\n label: 'Travel End',\n required: true,\n logic: [\n {\n type: 'derivation',\n targetProperty: 'minDate',\n expression: 'formValue.startDate',\n },\n ],\n },\n {\n key: 'notes',\n type: 'textarea',\n label: 'Notes',\n logic: [\n {\n type: 'derivation',\n targetProperty: 'props.appearance',\n value: 'fill',\n condition: {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'equals',\n value: 'DE',\n },\n },\n {\n type: 'derivation',\n targetProperty: 'props.appearance',\n value: 'outline',\n condition: {\n type: 'fieldValue',\n fieldPath: 'country',\n operator: 'notEquals',\n value: 'DE',\n },\n },\n ],\n },\n { key: 'submit', type: 'submit', label: 'Book Trip' },\n ],\n} as const satisfies FormConfig;\n```\n\n## Comparison with Value Derivation\n\n| Aspect | Value Derivation | Property Derivation |\n| ---------------- | ---------------------------- | --------------------------------------- |\n| Logic type | `type: 'derivation'` | `type: 'derivation'` + `targetProperty` |\n| Sets | Field's form value | Component input property |\n| Target | Implicit (self) | `targetProperty: 'minDate'` |\n| Shorthand | `derivation: 'expr'` | None (must use logic block) |\n| Chaining | Topologically sorted | No chaining (single pass) |\n| Custom functions | `customFnConfig.derivations` | `customFnConfig.derivations` |\n| Max iterations | Configurable (default 10) | Single pass |\n\n## Debugging\n\nAdd `debugName` to property derivations for easier identification in logs:\n\n```typescript\nlogic: [\n {\n type: 'derivation',\n debugName: 'endDate minDate constraint',\n targetProperty: 'minDate',\n expression: 'formValue.startDate',\n },\n];\n```\n\n## DerivationLogicConfig Interface\n\nBoth value derivations and property derivations use the unified `DerivationLogicConfig`. The presence of `targetProperty` is what distinguishes a property derivation from a value derivation.\n\n```typescript\ninterface DerivationLogicConfig {\n /** Logic type identifier (unified for both value and property derivations) */\n type: 'derivation';\n\n /** Property to set on the field component. When present, this is a property derivation. When absent, this is a value derivation. */\n targetProperty?: string;\n\n /** Optional name for debugging */\n debugName?: string;\n\n /** When to evaluate: 'onChange' (default) or 'debounced' */\n trigger?: 'onChange' | 'debounced';\n\n /** Debounce duration in ms (default: 500) */\n debounceMs?: number;\n\n /** Static value to set (mutually exclusive) */\n value?: unknown;\n\n /** JavaScript expression (mutually exclusive) */\n expression?: string;\n\n /** Name of registered custom function (mutually exclusive) */\n functionName?: string;\n\n /** Explicit field dependencies */\n dependsOn?: string[];\n\n /** Condition for when derivation applies (default: true) */\n condition?: ConditionalExpression | boolean;\n}\n```\n\n## External Data in Property Derivations\n\nUse external application state in property derivation custom functions:\n\n```typescript\nconst config = {\n externalData: {\n userRegion: computed(() => this.regionService.current()),\n },\n customFnConfig: {\n derivations: {\n getCurrencyOptions: (ctx) => {\n const optionsByRegion: Record<string, { label: string; value: string }[]> = {\n EU: [\n { label: 'EUR', value: 'eur' },\n { label: 'GBP', value: 'gbp' },\n ],\n US: [\n { label: 'USD', value: 'usd' },\n { label: 'CAD', value: 'cad' },\n ],\n };\n return optionsByRegion[ctx.externalData.userRegion as string] ?? [];\n },\n },\n },\n fields: [\n {\n key: 'currency',\n type: 'select',\n label: 'Currency',\n options: [],\n logic: [\n {\n type: 'derivation',\n targetProperty: 'options',\n functionName: 'getCurrencyOptions',\n },\n ],\n },\n ],\n} as const satisfies FormConfig;\n```\n\nExternal data values are reactively tracked - when signals change, property derivations are re-evaluated.\n\n## Related\n\n- **Value Derivation** (see tab above) - Compute field form values\n- **[Conditional Logic](../conditional-logic/overview/)** - Control field visibility and state\n- **[Array Fields](../../prebuilt/form-arrays/simplified/)** - Working with array fields"
|
|
64
82
|
},
|
|
65
83
|
{
|
|
66
84
|
id: "age-conditional-form",
|
|
@@ -68,6 +86,12 @@ var require_docs = __commonJS({
|
|
|
68
86
|
category: "examples",
|
|
69
87
|
content: "[\u2190 Back to Quick Start](/examples)\n\nRegistration form demonstrating numeric comparison operators for age-based conditional logic.\n\n## Live Demo\n\n<iframe src=\"http://localhost:4201/#/examples/age-conditional-form\" class=\"example-frame\" title=\"Age Conditional Form Demo\"></iframe>\n\n## Overview\n\nThis example shows how to use numeric comparison operators to display different fields based on age ranges. Minors see parental consent fields, while seniors see discount options.\n\n**Key patterns demonstrated:**\n\n- Age-appropriate field display\n- Numeric comparisons (greater than, less than)\n- Multiple conditional sections based on same field\n\n## Implementation\n\n```typescript\nimport { Component, signal } from '@angular/core';\nimport { DynamicForm, type FormConfig } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-age-conditional-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"config\" [(value)]=\"formValue\"></form>`,\n})\nexport class AgeConditionalFormComponent {\n formValue = signal({});\n\n config = {\n fields: [\n {\n key: 'name',\n type: 'input',\n value: '',\n label: 'Full Name',\n required: true,\n },\n {\n key: 'email',\n type: 'input',\n value: '',\n label: 'Email Address',\n required: true,\n email: true,\n },\n {\n key: 'age',\n type: 'input',\n value: null,\n label: 'Age',\n required: true,\n min: 0,\n max: 120,\n props: {\n type: 'number',\n hint: 'Enter your age to see relevant options',\n },\n },\n {\n key: 'parentalConsent',\n type: 'checkbox',\n value: false,\n label: 'I have parental/guardian consent to register',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greaterOrEqual',\n value: 18,\n },\n },\n {\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'less',\n value: 18,\n },\n },\n ],\n },\n {\n key: 'parentEmail',\n type: 'input',\n value: '',\n label: 'Parent/Guardian Email',\n email: true,\n props: {\n hint: 'We will send a verification email to your parent/guardian',\n },\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greaterOrEqual',\n value: 18,\n },\n },\n {\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'less',\n value: 18,\n },\n },\n ],\n },\n {\n key: 'seniorDiscount',\n type: 'checkbox',\n value: false,\n label: 'Apply senior discount (65+)',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'less',\n value: 65,\n },\n },\n ],\n },\n {\n key: 'aarpMember',\n type: 'checkbox',\n value: false,\n label: 'I am an AARP member (additional 5% discount)',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'less',\n value: 65,\n },\n },\n ],\n },\n {\n type: 'submit',\n key: 'submit',\n label: 'Register',\n },\n ],\n } as const satisfies FormConfig;\n}\n```\n\n## How It Works\n\n### Age-Based Visibility\n\nFields for minors are hidden when age >= 18:\n\n```typescript\n{\n key: 'parentalConsent',\n type: 'checkbox',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greaterOrEqual',\n value: 18,\n },\n }],\n}\n```\n\nFields for seniors are hidden when age < 65:\n\n```typescript\n{\n key: 'seniorDiscount',\n type: 'checkbox',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'less',\n value: 65,\n },\n }],\n}\n```\n\n### Conditional Required for Minors\n\nParent email is required only for minors:\n\n```typescript\n{\n key: 'parentEmail',\n type: 'input',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'greaterOrEqual',\n value: 18,\n },\n },\n {\n type: 'required',\n condition: {\n type: 'fieldValue',\n fieldPath: 'age',\n operator: 'less',\n value: 18,\n },\n },\n ],\n}\n```\n\n## Comparison Operators\n\nThis example uses numeric comparison operators:\n\n| Operator | Description |\n| ---------------- | -------------------------- |\n| `greater` | Greater than (>) |\n| `less` | Less than (<) |\n| `greaterOrEqual` | Greater than or equal (>=) |\n| `lessOrEqual` | Less than or equal (<=) |\n\n## Use Cases\n\n- Age-gated registration\n- Tiered pricing forms\n- Compliance forms with age requirements\n- Membership applications\n\n## Related Documentation\n\n- **[Conditional Logic](../../dynamic-behavior/conditional-logic/overview/)** - Full conditional logic guide\n- **[Validation](../../validation/basics/)** - Min/max validation\n- **[User Registration](../user-registration/)** - Basic registration example"
|
|
70
88
|
},
|
|
89
|
+
{
|
|
90
|
+
id: "array-form",
|
|
91
|
+
title: "Array Form",
|
|
92
|
+
category: "examples",
|
|
93
|
+
content: "[\u2190 Back to Quick Start](/examples)\n\nDynamic form arrays using the complete API with declarative add/remove buttons, custom button placement, and full control over item structure.\n\n## Live Demo\n\n<iframe src=\"http://localhost:4201/#/examples/array\" class=\"example-frame\" title=\"Array Field Demo\"></iframe>\n\n## Overview\n\nThis example demonstrates the complete array API with:\n\n- **Flat arrays** with row-based items (input + remove button)\n- **Object arrays** with multiple fields per item\n- **Declarative buttons** for add, prepend, and remove operations\n- **Custom button placement** outside the array container\n- **Template-based item creation**\n\n## Implementation\n\n```typescript\nimport { Component, signal } from '@angular/core';\nimport { DynamicForm, FormConfig } from '@ng-forge/dynamic-forms';\n\n// Template for new tags \u2014 a row with an input and remove button\nconst tagTemplate = {\n key: 'tag',\n type: 'row',\n fields: [\n {\n key: 'value',\n type: 'input',\n label: 'Tag',\n required: true,\n minLength: 2,\n props: { placeholder: 'Enter a tag', hint: 'Tags must be at least 2 characters' },\n },\n {\n key: 'removeTag',\n type: 'removeArrayItem',\n label: 'Remove',\n className: 'remove-tag-button',\n props: { color: 'warn' },\n },\n ],\n} as const;\n\n// Template for new contacts \u2014 includes remove button inside each item\nconst contactTemplate = [\n {\n key: 'name',\n type: 'input',\n label: 'Contact Name',\n required: true,\n minLength: 2,\n props: { placeholder: 'Enter contact name', hint: 'Full name of the emergency contact' },\n },\n {\n key: 'phone',\n type: 'input',\n label: 'Phone Number',\n required: true,\n pattern: /^\\d{10}$/,\n validationMessages: { pattern: 'Please enter a valid 10-digit phone number' },\n props: { type: 'tel', placeholder: '5551234567' },\n },\n {\n key: 'relationship',\n type: 'select',\n label: 'Relationship',\n required: true,\n options: [\n { label: 'Family', value: 'family' },\n { label: 'Friend', value: 'friend' },\n { label: 'Colleague', value: 'colleague' },\n { label: 'Other', value: 'other' },\n ],\n },\n {\n key: 'removeContact',\n type: 'removeArrayItem',\n label: 'Remove Contact',\n className: 'remove-contact-button',\n props: { color: 'warn' },\n },\n] as const;\n\n@Component({\n selector: 'app-array-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"config\" [(value)]=\"formValue\"></form>`,\n})\nexport class ArrayFormComponent {\n formValue = signal({});\n\n config = {\n fields: [\n // Flat array with row-based items\n {\n key: 'tags',\n type: 'array',\n fields: [\n [\n {\n key: 'tag',\n type: 'row',\n fields: [\n { key: 'value', type: 'input', label: 'Tag', value: 'angular', required: true, minLength: 2 },\n { key: 'removeTag', type: 'removeArrayItem', label: 'Remove', props: { color: 'warn' } },\n ],\n },\n ],\n [\n {\n key: 'tag',\n type: 'row',\n fields: [\n { key: 'value', type: 'input', label: 'Tag', value: 'typescript', required: true, minLength: 2 },\n { key: 'removeTag', type: 'removeArrayItem', label: 'Remove', props: { color: 'warn' } },\n ],\n },\n ],\n ],\n },\n // Add button placed outside the array\n {\n key: 'addTagButton',\n type: 'addArrayItem',\n label: 'Add Tag',\n arrayKey: 'tags',\n template: [tagTemplate],\n props: { color: 'primary' },\n },\n\n // Object array with multiple fields per item\n {\n key: 'contacts',\n type: 'array',\n fields: [\n [\n { key: 'name', type: 'input', label: 'Contact Name', value: 'Jane Smith', required: true, minLength: 2 },\n { key: 'phone', type: 'input', label: 'Phone Number', value: '5551234567', required: true },\n {\n key: 'relationship',\n type: 'select',\n label: 'Relationship',\n value: 'family',\n required: true,\n options: [\n { label: 'Family', value: 'family' },\n { label: 'Friend', value: 'friend' },\n { label: 'Colleague', value: 'colleague' },\n { label: 'Other', value: 'other' },\n ],\n },\n { key: 'removeContact', type: 'removeArrayItem', label: 'Remove Contact', props: { color: 'warn' } },\n ],\n ],\n },\n // Multiple add buttons with different behaviors\n {\n key: 'contactButtons',\n type: 'row',\n fields: [\n {\n key: 'prependContactButton',\n type: 'prependArrayItem',\n label: 'Add First',\n arrayKey: 'contacts',\n template: contactTemplate,\n props: { color: 'accent' },\n },\n {\n key: 'addContactButton',\n type: 'addArrayItem',\n label: 'Add Contact',\n arrayKey: 'contacts',\n template: contactTemplate,\n props: { color: 'primary' },\n },\n ],\n },\n\n { key: 'submit', type: 'submit', label: 'Save All', props: { color: 'primary' } },\n ],\n } as const satisfies FormConfig;\n}\n```\n\n## Key Features\n\n### Declarative Add/Remove Buttons\n\nUse `addArrayItem`, `prependArrayItem`, and `removeArrayItem` button types directly in your config:\n\n```typescript\n// Add button outside the array\n{\n key: 'addTag',\n type: 'addArrayItem',\n label: 'Add Tag',\n arrayKey: 'tags', // Points to the array field\n template: tagTemplate, // Defines the new item structure\n}\n\n// Remove button inside each array item\n{\n key: 'removeTag',\n type: 'removeArrayItem',\n label: 'Remove',\n}\n```\n\n### Custom Button Placement\n\nPlace add/prepend buttons anywhere in the form layout:\n\n```typescript\n{\n key: 'contactButtons',\n type: 'row',\n fields: [\n { key: 'prepend', type: 'prependArrayItem', label: 'Add First', arrayKey: 'contacts', template: contactTemplate },\n { key: 'append', type: 'addArrayItem', label: 'Add Last', arrayKey: 'contacts', template: contactTemplate },\n ],\n}\n```\n\n### Templates for New Items\n\nEach add button defines its own template for what to insert:\n\n```typescript\nconst contactTemplate = [\n { key: 'name', type: 'input', label: 'Name' },\n { key: 'phone', type: 'input', label: 'Phone' },\n { key: 'remove', type: 'removeArrayItem', label: 'Remove' },\n];\n```\n\n## Use Cases\n\n- Forms requiring custom button placement\n- Arrays with multiple add operations (append, prepend, insert)\n- Heterogeneous arrays with different item structures\n- Complex layouts with row-based array items\n\n## Related Examples\n\n- **[Simplified Array Form](../simplified-array-form/)** - Simpler API with auto-generated buttons\n- **[Paginated Form](../paginated-form/)** - Multi-step wizard form\n- **[Contact Form](../contact-form/)** - Basic contact form\n\n## Related Documentation\n\n- **[Complete Array API](../../prebuilt/form-arrays/complete/)** - Full API reference\n- **[Simplified Array API](../../prebuilt/form-arrays/simplified/)** - Simpler alternative\n- **[Validation](../../validation/basics/)** - Form validation guide"
|
|
94
|
+
},
|
|
71
95
|
{
|
|
72
96
|
id: "business-account-form",
|
|
73
97
|
title: "Business Account Form",
|
|
@@ -110,6 +134,12 @@ var require_docs = __commonJS({
|
|
|
110
134
|
category: "examples",
|
|
111
135
|
content: "[\u2190 Back to Quick Start](/examples)\n\nCheckout form demonstrating the common \"same as billing\" pattern for shipping addresses.\n\n## Live Demo\n\n<iframe src=\"http://localhost:4201/#/examples/shipping-billing-address\" class=\"example-frame\" title=\"Shipping Billing Address Demo\"></iframe>\n\n## Overview\n\nThis example shows how to toggle an entire group of fields based on a checkbox. When \"Shipping same as billing\" is checked, the shipping address fields are hidden.\n\n**Key patterns demonstrated:**\n\n- Entire group hidden/shown with single condition\n- Checkbox controls form complexity\n- Reduces user effort when addresses are the same\n\n## Implementation\n\n```typescript\nimport { Component, signal } from '@angular/core';\nimport { DynamicForm, type FormConfig } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-shipping-billing-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"config\" [(value)]=\"formValue\"></form>`,\n})\nexport class ShippingBillingFormComponent {\n formValue = signal({});\n\n config = {\n fields: [\n {\n key: 'billingTitle',\n type: 'text',\n label: 'Billing Address',\n props: { elementType: 'h4' },\n },\n {\n key: 'billingAddress',\n type: 'group',\n fields: [\n {\n key: 'street',\n type: 'input',\n value: '',\n label: 'Street Address',\n required: true,\n },\n {\n key: 'city',\n type: 'input',\n value: '',\n label: 'City',\n required: true,\n },\n {\n key: 'state',\n type: 'input',\n value: '',\n label: 'State/Province',\n required: true,\n },\n {\n key: 'zipCode',\n type: 'input',\n value: '',\n label: 'ZIP/Postal Code',\n required: true,\n },\n ],\n },\n {\n key: 'sameAsBilling',\n type: 'checkbox',\n value: false,\n label: 'Shipping address is same as billing address',\n },\n {\n key: 'shippingTitle',\n type: 'text',\n label: 'Shipping Address',\n props: { elementType: 'h4' },\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'sameAsBilling',\n operator: 'equals',\n value: true,\n },\n },\n ],\n },\n {\n key: 'shippingAddress',\n type: 'group',\n logic: [\n {\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'sameAsBilling',\n operator: 'equals',\n value: true,\n },\n },\n ],\n fields: [\n {\n key: 'street',\n type: 'input',\n value: '',\n label: 'Street Address',\n required: true,\n },\n {\n key: 'city',\n type: 'input',\n value: '',\n label: 'City',\n required: true,\n },\n {\n key: 'state',\n type: 'input',\n value: '',\n label: 'State/Province',\n required: true,\n },\n {\n key: 'zipCode',\n type: 'input',\n value: '',\n label: 'ZIP/Postal Code',\n required: true,\n },\n ],\n },\n {\n type: 'submit',\n key: 'submit',\n label: 'Continue to Payment',\n },\n ],\n } as const satisfies FormConfig;\n}\n```\n\n## How It Works\n\n### Checkbox Toggle\n\nA simple checkbox controls whether shipping fields are visible:\n\n```typescript\n{\n key: 'sameAsBilling',\n type: 'checkbox',\n value: false,\n label: 'Shipping address is same as billing address',\n}\n```\n\n### Group Visibility\n\nThe entire shipping address group is hidden when the checkbox is checked:\n\n```typescript\n{\n key: 'shippingAddress',\n type: 'group',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'sameAsBilling',\n operator: 'equals',\n value: true,\n },\n }],\n fields: [\n // All shipping fields...\n ],\n}\n```\n\n### Section Titles\n\nThe section title is also hidden along with the group:\n\n```typescript\n{\n key: 'shippingTitle',\n type: 'text',\n label: 'Shipping Address',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'sameAsBilling',\n operator: 'equals',\n value: true,\n },\n }],\n}\n```\n\n## Use Cases\n\n- E-commerce checkout flows\n- Order forms\n- Account billing setup\n- Subscription management\n\n## Related Documentation\n\n- **[Conditional Logic](../../dynamic-behavior/conditional-logic/overview/)** - Full conditional logic guide\n- **[Form Groups](../../prebuilt/form-groups/)** - Working with groups\n- **[Checkbox Fields](../../schema-fields/field-types/)** - Field types reference"
|
|
112
136
|
},
|
|
137
|
+
{
|
|
138
|
+
id: "simplified-array-form",
|
|
139
|
+
title: "Simplified Array Form",
|
|
140
|
+
category: "examples",
|
|
141
|
+
content: "[\u2190 Back to Quick Start](/examples)\n\nDynamic arrays using the simplified API with auto-generated add/remove buttons, template-based item definitions, and both primitive and object arrays.\n\n## Live Demo\n\n<iframe src=\"http://localhost:4201/#/examples/simplified-array\" class=\"example-frame\" title=\"Simplified Array Demo\"></iframe>\n\n## Overview\n\nThis example showcases the simplified array API with:\n\n- **Primitive arrays** (tags) with flat scalar values\n- **Object arrays** (contacts) with multiple fields per item\n- **Empty arrays** that start with no items\n- **Button customization** including opt-out\n- **Auto-generated add/remove buttons**\n\n## Implementation\n\n```typescript\nimport { Component, signal } from '@angular/core';\nimport { DynamicForm, FormConfig } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-simplified-array-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"config\" [(value)]=\"formValue\"></form>`,\n})\nexport class SimplifiedArrayFormComponent {\n formValue = signal({});\n\n config = {\n fields: [\n // Primitive array \u2014 tags\n {\n key: 'tags',\n type: 'array',\n template: {\n key: 'value',\n type: 'input',\n label: 'Tag',\n required: true,\n minLength: 2,\n props: { placeholder: 'Enter a tag' },\n },\n value: ['angular', 'typescript'],\n addButton: { label: 'Add Tag', props: { color: 'primary' } },\n removeButton: { label: 'Remove', props: { color: 'warn' } },\n },\n\n // Object array \u2014 contacts\n {\n key: 'contacts',\n type: 'array',\n template: [\n {\n key: 'name',\n type: 'input',\n label: 'Contact Name',\n required: true,\n minLength: 2,\n props: { placeholder: 'Enter contact name' },\n },\n {\n key: 'phone',\n type: 'input',\n label: 'Phone Number',\n props: { type: 'tel', placeholder: '5551234567' },\n },\n ],\n value: [\n { name: 'Jane Smith', phone: '5551234567' },\n { name: 'John Doe', phone: '5559876543' },\n ],\n addButton: { label: 'Add Contact', props: { color: 'primary' } },\n },\n\n // Empty array \u2014 notes\n {\n key: 'notes',\n type: 'array',\n template: {\n key: 'text',\n type: 'input',\n label: 'Note',\n props: { placeholder: 'Enter a note' },\n },\n addButton: { label: 'Add Note' },\n },\n\n // No remove button \u2014 categories\n {\n key: 'categories',\n type: 'array',\n template: { key: 'name', type: 'input', label: 'Category' },\n value: ['Frontend', 'Backend'],\n removeButton: false,\n addButton: { label: 'Add Category' },\n },\n\n { key: 'submit', type: 'submit', label: 'Save All', props: { color: 'primary' } },\n ],\n } as const satisfies FormConfig;\n}\n```\n\n## Key Features\n\n### Template-Based Items\n\nDefine the item structure once via `template`, and provide initial data via `value`:\n\n```typescript\n{\n key: 'tags',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Tag', required: true },\n value: ['angular', 'typescript'],\n}\n```\n\n### Auto-Generated Buttons\n\nAdd and remove buttons are generated automatically. Customize labels and props:\n\n```typescript\naddButton: { label: 'Add Tag', props: { color: 'primary' } },\nremoveButton: { label: 'Remove', props: { color: 'warn' } },\n```\n\n### Button Opt-Out\n\nDisable buttons entirely with `false`:\n\n```typescript\nremoveButton: false, // No remove buttons on items\naddButton: false, // No add button\n```\n\n## Use Cases\n\n- Tag lists and keyword management\n- Contact lists with multiple fields\n- Dynamic note-taking\n- Category management\n- Any repeating data entry\n\n## Related Examples\n\n- **[Array Form (Complete)](../array-form/)** - Full control with declarative buttons and EventBus\n- **[Paginated Form](../paginated-form/)** - Multi-step wizard form\n- **[Contact Form](../contact-form/)** - Basic contact form\n\n## Related Documentation\n\n- **[Simplified Array API](../../prebuilt/form-arrays/simplified/)** - Full API reference\n- **[Complete Array API](../../prebuilt/form-arrays/complete/)** - Advanced array features\n- **[Validation](../../validation/basics/)** - Form validation guide"
|
|
142
|
+
},
|
|
113
143
|
{
|
|
114
144
|
id: "user-registration",
|
|
115
145
|
title: "User Registration",
|
|
@@ -126,37 +156,43 @@ var require_docs = __commonJS({
|
|
|
126
156
|
id: "index",
|
|
127
157
|
title: "Index",
|
|
128
158
|
category: "installation",
|
|
129
|
-
content: 'Get ng-forge dynamic forms up and running in your Angular project.\n\n## Requirements\n\n- **Angular 21+** - ng-forge dynamic forms requires Angular 21 or higher for signal forms support\n- **TypeScript 5.6+** - For best type inference results\n\n## Installation\n\nInstall the core library and your preferred UI integration:\n\n```bash group="install" name="npm"\nnpm install @ng-forge/dynamic-forms @ng-forge/dynamic-forms-material\n```\n\n```bash group="install" name="yarn"\nyarn add @ng-forge/dynamic-forms @ng-forge/dynamic-forms-material\n```\n\n```bash group="install" name="pnpm"\npnpm add @ng-forge/dynamic-forms @ng-forge/dynamic-forms-material\n```\n\n> This installs the core `@ng-forge/dynamic-forms` package and the Material Design integration. See [UI Framework Options](#ui-framework-options) below for other choices.\n\n## Configure Your App\n\nAdd the dynamic form provider to your app configuration:\n\n```typescript name="app.config.ts"\nimport { ApplicationConfig } from \'@angular/core\';\nimport { provideDynamicForm } from \'@ng-forge/dynamic-forms\';\nimport { withMaterialFields } from \'@ng-forge/dynamic-forms-material\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [\n provideDynamicForm(...withMaterialFields()),\n // ... other providers\n ],\n};\n```\n\nThe `provideDynamicForm()` function registers field types and validators. The `withMaterialFields()` function provides all Material Design field components.\n\n## Create Your First Form\n\nCreate a simple login form to verify everything works:\n\n```typescript name="login.component.ts"\nimport { Component } from \'@angular/core\';\nimport { DynamicForm, type FormConfig } from \'@ng-forge/dynamic-forms\';\n\n@Component({\n selector: \'app-login\',\n imports: [DynamicForm],\n template: `<form [dynamic-form]="config"></form>`,\n})\nexport class LoginComponent {\n config = {\n fields: [\n {\n key: \'email\',\n type: \'input\',\n value: \'\',\n label: \'Email\',\n required: true,\n email: true,\n },\n {\n key: \'password\',\n type: \'input\',\n value: \'\',\n label: \'Password\',\n required: true,\n minLength: 8,\n props: { type: \'password\' },\n },\n {\n type: \'submit\',\n key: \'submit\',\n label: \'Sign In\',\n props: { color: \'primary\' },\n },\n ],\n } as const satisfies FormConfig;\n}\n```\n\nYou now have a working form with:\n\n- \u2705 Real-time validation with error messages\n- \u2705 TypeScript type inference\n- \u2705 Material Design styling\n- \u2705 Accessibility support\n- \u2705 Submit button auto-disables when invalid\n\n## UI Framework Options\n\nng-forge dynamic forms supports multiple UI frameworks. Install the integration package for your preferred framework:\n\n### Material Design (Preview)\n\n```bash group="install-material" name="npm"\nnpm install @ng-forge/dynamic-forms-material\n```\n\n```bash group="install-material" name="yarn"\nyarn add @ng-forge/dynamic-forms-material\n```\n\n```bash group="install-material" name="pnpm"\npnpm add @ng-forge/dynamic-forms-material\n```\n\n```typescript\nimport { withMaterialFields } from \'@ng-forge/dynamic-forms-material\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [provideDynamicForm(...withMaterialFields())],\n};\n```\n\nSee [Material Integration](../ui-libs-integrations/material) for full documentation.\n\n### PrimeNG (Preview)\n\n```bash group="install-primeng" name="npm"\nnpm install @ng-forge/dynamic-forms-primeng\n```\n\n```bash group="install-primeng" name="yarn"\nyarn add @ng-forge/dynamic-forms-primeng\n```\n\n```bash group="install-primeng" name="pnpm"\npnpm add @ng-forge/dynamic-forms-primeng\n```\n\n```typescript\nimport { withPrimeNGFields } from \'@ng-forge/dynamic-forms-primeng\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [provideDynamicForm(...withPrimeNGFields())],\n};\n```\n\nSee [PrimeNG Integration](../ui-libs-integrations/primeng) for full documentation.\n\n### Bootstrap (Preview)\n\n```bash group="install-bootstrap" name="npm"\nnpm install @ng-forge/dynamic-forms-bootstrap\n```\n\n```bash group="install-bootstrap" name="yarn"\nyarn add @ng-forge/dynamic-forms-bootstrap\n```\n\n```bash group="install-bootstrap" name="pnpm"\npnpm add @ng-forge/dynamic-forms-bootstrap\n```\n\n```typescript\nimport { withBootstrapFields } from \'@ng-forge/dynamic-forms-bootstrap\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [provideDynamicForm(...withBootstrapFields())],\n};\n```\n\nSee [Bootstrap Integration](../ui-libs-integrations/bootstrap) for full documentation.\n\n### Ionic (Preview)\n\n```bash group="install-ionic" name="npm"\nnpm install @ng-forge/dynamic-forms-ionic\n```\n\n```bash group="install-ionic" name="yarn"\nyarn add @ng-forge/dynamic-forms-ionic\n```\n\n```bash group="install-ionic" name="pnpm"\npnpm add @ng-forge/dynamic-forms-ionic\n```\n\n```typescript\nimport { withIonicFields } from \'@ng-forge/dynamic-forms-ionic\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [provideDynamicForm(...withIonicFields())],\n};\n```\n\nSee [Ionic Integration](../ui-libs-integrations/ionic) for full documentation.\n\n### Custom UI Components\n\nYou can also build your own field components using any UI library or custom styling:\n\n```typescript\nimport { MyCustomInputComponent } from \'./my-custom-input.component\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [\n provideDynamicForm([\n { name: \'input\', loadComponent: () => MyCustomInputComponent },\n // ... more custom field types\n ]),\n ],\n};\n```\n\nSee [Custom Integration Guide](../advanced/custom-integrations) for building custom field components.\n\n## Next Steps\n\nNow that you have ng-forge dynamic forms installed, explore the core features:\n\n### Learn Core Concepts\n\n- **[Field Types](../schema-fields/field-types)** - Understand all available field types (input, select, checkbox, group, etc.)\n- **[Validation](../validation/basics)** - Add validation rules with shorthand syntax or conditional validators\n- **[Conditional Logic](../dynamic-behavior/conditional-logic/overview)** - Show/hide fields based on other field values\n- **[Type Safety](../advanced/type-safety/basics)** - TypeScript type inference for forms\n\n### Build Advanced Forms\n\n- **[Multi-Step Forms](../
|
|
159
|
+
content: 'Get ng-forge dynamic forms up and running in your Angular project.\n\n## Requirements\n\n- **Angular 21+** - ng-forge dynamic forms requires Angular 21 or higher for signal forms support\n- **TypeScript 5.6+** - For best type inference results\n\n## Installation\n\nInstall the core library and your preferred UI integration:\n\n```bash group="install" name="npm"\nnpm install @ng-forge/dynamic-forms @ng-forge/dynamic-forms-material\n```\n\n```bash group="install" name="yarn"\nyarn add @ng-forge/dynamic-forms @ng-forge/dynamic-forms-material\n```\n\n```bash group="install" name="pnpm"\npnpm add @ng-forge/dynamic-forms @ng-forge/dynamic-forms-material\n```\n\n> This installs the core `@ng-forge/dynamic-forms` package and the Material Design integration. See [UI Framework Options](#ui-framework-options) below for other choices.\n\n## Configure Your App\n\nAdd the dynamic form provider to your app configuration:\n\n```typescript name="app.config.ts"\nimport { ApplicationConfig } from \'@angular/core\';\nimport { provideDynamicForm } from \'@ng-forge/dynamic-forms\';\nimport { withMaterialFields } from \'@ng-forge/dynamic-forms-material\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [\n provideDynamicForm(...withMaterialFields()),\n // ... other providers\n ],\n};\n```\n\nThe `provideDynamicForm()` function registers field types and validators. The `withMaterialFields()` function provides all Material Design field components.\n\n## Create Your First Form\n\nCreate a simple login form to verify everything works:\n\n```typescript name="login.component.ts"\nimport { Component } from \'@angular/core\';\nimport { DynamicForm, type FormConfig } from \'@ng-forge/dynamic-forms\';\n\n@Component({\n selector: \'app-login\',\n imports: [DynamicForm],\n template: `<form [dynamic-form]="config"></form>`,\n})\nexport class LoginComponent {\n config = {\n fields: [\n {\n key: \'email\',\n type: \'input\',\n value: \'\',\n label: \'Email\',\n required: true,\n email: true,\n },\n {\n key: \'password\',\n type: \'input\',\n value: \'\',\n label: \'Password\',\n required: true,\n minLength: 8,\n props: { type: \'password\' },\n },\n {\n type: \'submit\',\n key: \'submit\',\n label: \'Sign In\',\n props: { color: \'primary\' },\n },\n ],\n } as const satisfies FormConfig;\n}\n```\n\nYou now have a working form with:\n\n- \u2705 Real-time validation with error messages\n- \u2705 TypeScript type inference\n- \u2705 Material Design styling\n- \u2705 Accessibility support\n- \u2705 Submit button auto-disables when invalid\n\n## UI Framework Options\n\nng-forge dynamic forms supports multiple UI frameworks. Install the integration package for your preferred framework:\n\n### Material Design (Preview)\n\n```bash group="install-material" name="npm"\nnpm install @ng-forge/dynamic-forms-material\n```\n\n```bash group="install-material" name="yarn"\nyarn add @ng-forge/dynamic-forms-material\n```\n\n```bash group="install-material" name="pnpm"\npnpm add @ng-forge/dynamic-forms-material\n```\n\n```typescript\nimport { withMaterialFields } from \'@ng-forge/dynamic-forms-material\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [provideDynamicForm(...withMaterialFields())],\n};\n```\n\nSee [Material Integration](../ui-libs-integrations/material) for full documentation.\n\n### PrimeNG (Preview)\n\n```bash group="install-primeng" name="npm"\nnpm install @ng-forge/dynamic-forms-primeng\n```\n\n```bash group="install-primeng" name="yarn"\nyarn add @ng-forge/dynamic-forms-primeng\n```\n\n```bash group="install-primeng" name="pnpm"\npnpm add @ng-forge/dynamic-forms-primeng\n```\n\n```typescript\nimport { withPrimeNGFields } from \'@ng-forge/dynamic-forms-primeng\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [provideDynamicForm(...withPrimeNGFields())],\n};\n```\n\nSee [PrimeNG Integration](../ui-libs-integrations/primeng) for full documentation.\n\n### Bootstrap (Preview)\n\n```bash group="install-bootstrap" name="npm"\nnpm install @ng-forge/dynamic-forms-bootstrap\n```\n\n```bash group="install-bootstrap" name="yarn"\nyarn add @ng-forge/dynamic-forms-bootstrap\n```\n\n```bash group="install-bootstrap" name="pnpm"\npnpm add @ng-forge/dynamic-forms-bootstrap\n```\n\n```typescript\nimport { withBootstrapFields } from \'@ng-forge/dynamic-forms-bootstrap\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [provideDynamicForm(...withBootstrapFields())],\n};\n```\n\nSee [Bootstrap Integration](../ui-libs-integrations/bootstrap) for full documentation.\n\n### Ionic (Preview)\n\n```bash group="install-ionic" name="npm"\nnpm install @ng-forge/dynamic-forms-ionic\n```\n\n```bash group="install-ionic" name="yarn"\nyarn add @ng-forge/dynamic-forms-ionic\n```\n\n```bash group="install-ionic" name="pnpm"\npnpm add @ng-forge/dynamic-forms-ionic\n```\n\n```typescript\nimport { withIonicFields } from \'@ng-forge/dynamic-forms-ionic\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [provideDynamicForm(...withIonicFields())],\n};\n```\n\nSee [Ionic Integration](../ui-libs-integrations/ionic) for full documentation.\n\n### Custom UI Components\n\nYou can also build your own field components using any UI library or custom styling:\n\n```typescript\nimport { MyCustomInputComponent } from \'./my-custom-input.component\';\n\nexport const appConfig: ApplicationConfig = {\n providers: [\n provideDynamicForm([\n { name: \'input\', loadComponent: () => MyCustomInputComponent },\n // ... more custom field types\n ]),\n ],\n};\n```\n\nSee [Custom Integration Guide](../advanced/custom-integrations) for building custom field components.\n\n## Next Steps\n\nNow that you have ng-forge dynamic forms installed, explore the core features:\n\n### Learn Core Concepts\n\n- **[Field Types](../schema-fields/field-types)** - Understand all available field types (input, select, checkbox, group, etc.)\n- **[Validation](../validation/basics)** - Add validation rules with shorthand syntax or conditional validators\n- **[Conditional Logic](../dynamic-behavior/conditional-logic/overview)** - Show/hide fields based on other field values\n- **[Type Safety](../advanced/type-safety/basics)** - TypeScript type inference for forms\n\n### Build Advanced Forms\n\n- **[Multi-Step Forms](../prebuilt/form-pages)** - Create wizard-style forms with page navigation\n- **[Repeatable Sections](../prebuilt/form-arrays/simplified)** - Dynamic form arrays for adding/removing fields\n- **[Conditional Validation](../validation/advanced#conditional-validators)** - Validators that activate based on conditions\n\n### Customize and Extend\n\n- **[i18n Setup](../dynamic-behavior/i18n)** - Add multi-language support to your forms\n- **[Events](../advanced/events)** - Handle custom form events\n- **[Custom Fields](../advanced/custom-integrations)** - Create your own field types\n\n## Get Help\n\n- \u{1F4AC} **[GitHub Discussions](https://github.com/ng-forge/ng-forge/discussions)** - Ask questions and get help\n- \u{1F41B} **[Issue Tracker](https://github.com/ng-forge/ng-forge/issues)** - Report bugs\n- \u{1F4D6} **[Documentation](../)** - Browse full documentation'
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "form-arrays-complete",
|
|
163
|
+
title: "Form Arrays Complete",
|
|
164
|
+
category: "prebuilt",
|
|
165
|
+
content: "Arrays create dynamic collections of field values. Each item in the `fields` array defines **one array item** with its structure and initial values.\n\n> For most use cases, the **simplified array API** is recommended. See [Form Arrays (Simplified)](/prebuilt/form-arrays/simplified) for details. This page documents the complete API for advanced use cases.\n\n## Interactive Demo\n\n<iframe src=\"http://localhost:4201/#/examples/array\" class=\"example-frame\" title=\"Array Field Demo\"></iframe>\n\n## Structure Overview\n\nThe `fields` property supports two item formats:\n\n- **Single FieldDef** (not wrapped in array) \u2192 **Primitive item** - extracts field value directly\n- **Array of FieldDefs** \u2192 **Object item** - merges fields into an object\n\n```typescript\n// Primitive array: ['angular', 'typescript']\n{\n key: 'tags',\n type: 'array',\n fields: [\n { key: 'tag', type: 'input', value: 'angular' }, // Single field = primitive\n { key: 'tag', type: 'input', value: 'typescript' },\n ]\n}\n\n// Object array: [{ name: 'Alice', email: '...' }]\n{\n key: 'contacts',\n type: 'array',\n fields: [\n [ // Array of fields = object\n { key: 'name', type: 'input', label: 'Name', value: 'Alice' },\n { key: 'email', type: 'input', label: 'Email', value: 'alice@example.com' }\n ],\n ]\n}\n```\n\n## Empty Arrays (No Initial Items)\n\nFor arrays that start empty and are populated via buttons:\n\n```typescript\n{\n key: 'tags',\n type: 'array',\n fields: [] // No initial items - user adds via button\n}\n```\n\n## Array Size Validation\n\nUse `minLength` and `maxLength` on the array field to constrain the number of items. When violated, the form becomes invalid (e.g. submit button is disabled), but no field-level error message is shown.\n\n```typescript\n{\n key: 'tags',\n type: 'array',\n minLength: 1, // At least 1 item required\n maxLength: 5, // No more than 5 items\n fields: [\n [{ key: 'tag', type: 'input', label: 'Tag', value: 'default' }]\n ]\n}\n```\n\n| Property | Type | Description |\n| ----------- | -------- | ----------------------------- |\n| `minLength` | `number` | Minimum number of array items |\n| `maxLength` | `number` | Maximum number of array items |\n\nBoth properties are optional and can be used independently or together.\n\n## Initial Values\n\nInitial values are defined directly on each field via the `value` property - no separate `initialValue` is needed:\n\n```typescript\n{\n key: 'emails',\n type: 'array',\n fields: [\n // One initial item with pre-filled value (object item)\n [\n { key: 'email', type: 'input', label: 'Email', value: 'primary@example.com' }\n ]\n ]\n}\n```\n\n## Primitive Arrays (Simple Value Lists)\n\nFor simple arrays of primitive values like `['tag1', 'tag2']`, use a **single FieldDef per item** (not wrapped in an array):\n\n```typescript\n{\n key: 'tags',\n type: 'array',\n fields: [\n { key: 'tag', type: 'input', label: 'Tag', value: 'featured' },\n { key: 'tag', type: 'input', label: 'Tag', value: 'popular' }\n ]\n}\n```\n\nThis creates a true primitive array in the form value:\n\n```typescript\n{\n tags: ['featured', 'popular'];\n}\n```\n\nThe `key` property on each field is used for internal tracking but doesn't affect the output value.\n\n## Object Arrays (Multiple Fields per Item)\n\nFor arrays of objects with multiple fields, wrap the fields in an **inner array**:\n\n```typescript\n{\n key: 'contacts',\n type: 'array',\n fields: [\n [\n { key: 'name', type: 'input', label: 'Name', value: '' },\n { key: 'phone', type: 'input', label: 'Phone', value: '' }\n ]\n ]\n}\n```\n\nThis creates an array of objects:\n\n```typescript\n{\n contacts: [{ name: '', phone: '' }];\n}\n```\n\n## Nested Object Arrays (Using Groups)\n\nFor arrays with nested object structure, use a group field:\n\n```typescript\n{\n key: 'contacts',\n type: 'array',\n fields: [\n [\n {\n key: 'contact',\n type: 'group',\n fields: [\n { key: 'name', type: 'input', label: 'Name', value: '' },\n { key: 'phone', type: 'input', label: 'Phone', value: '' }\n ]\n }\n ]\n ]\n}\n```\n\nThis creates an array of nested objects (note the group key creates the nesting):\n\n```typescript\n{\n contacts: [{ contact: { name: '', phone: '' } }];\n}\n```\n\n## Heterogeneous Arrays (Mixed Primitives and Objects)\n\nArrays can contain both primitive and object items in the same array:\n\n```typescript\n{\n key: 'items',\n type: 'array',\n fields: [\n [{ key: 'label', type: 'input', value: 'Structured' }], // Object item (wrapped in array)\n { key: 'value', type: 'input', value: 'Simple' }, // Primitive item (single field)\n ]\n}\n```\n\nThis creates a heterogeneous array:\n\n```typescript\n{\n items: [{ label: 'Structured' }, 'Simple'];\n}\n```\n\n## Array vs Group\n\n- **Groups** create nested objects with keys: `{ address: { street: '', city: '' } }`\n- **Primitive Arrays** create lists of values: `{ tags: ['value1', 'value2'] }`\n- **Object Arrays** create lists of objects: `{ items: [{name: ''}, {name: ''}] }`\n\n## Dynamic Add/Remove\n\n### Declarative Approach (Recommended)\n\nUse button field types directly in your form configuration for declarative array manipulation:\n\n| Button Type | Placement | Description |\n| ------------------ | ---------------- | ------------------------------------------------ |\n| `addArrayItem` | Outside array | Appends a new item to the end of the array |\n| `prependArrayItem` | Outside array | Inserts a new item at the beginning of the array |\n| `insertArrayItem` | Outside array | Inserts a new item at a specific index |\n| `removeArrayItem` | Inside each item | Removes the current item from the array |\n| `popArrayItem` | Outside array | Removes the last item from the array |\n| `shiftArrayItem` | Outside array | Removes the first item from the array |\n\n**Important:** Add/prepend/insert buttons **require** a `template` property defining the new item structure. There is no fallback to the array's `fields[0]` - each button must explicitly define what structure to add.\n\nThe `template` can be:\n\n- **Single FieldDef** - creates a primitive item (field value is extracted directly)\n- **Array of FieldDefs** - creates an object item (fields merged into object)\n\n```typescript\n// Template for PRIMITIVE items (single field, not wrapped)\nconst tagTemplate = { key: 'tag', type: 'input', label: 'Tag' };\n\n// Template for OBJECT items (array of fields)\nconst contactTemplate = [\n { key: 'name', type: 'input', label: 'Name' },\n { key: 'phone', type: 'input', label: 'Phone' },\n // Remove button inside each item (no template needed)\n { key: 'remove', type: 'removeArrayItem', label: 'Remove' },\n];\n\n// Form configuration\n{\n fields: [\n {\n key: 'tags',\n type: 'array',\n fields: [], // Start empty - primitive array\n },\n // Add button for primitive items\n {\n key: 'addTag',\n type: 'addArrayItem',\n label: 'Add Tag',\n arrayKey: 'tags',\n template: tagTemplate, // Single field = primitive item\n },\n {\n key: 'contacts',\n type: 'array',\n fields: [], // Start empty - object array\n },\n // Add button for object items\n {\n key: 'addContact',\n type: 'addArrayItem',\n label: 'Add Contact',\n arrayKey: 'contacts',\n template: contactTemplate, // Array of fields = object item\n },\n ];\n}\n```\n\n### Programmatic Approach\n\nFor more control, use the event bus with the `arrayEvent` builder. **Note:** When using the programmatic approach, you must provide a template:\n\n```typescript\nimport { EventBus, arrayEvent } from '@ng-forge/dynamic-forms';\n\n// Inject the event bus\neventBus = inject(EventBus);\n\n// Template for PRIMITIVE items (single field, not wrapped)\ntagTemplate = { key: 'tag', type: 'input', label: 'Tag', value: '' };\n\n// Template for OBJECT items (array of fields)\ncontactTemplate = [\n { key: 'name', type: 'input', label: 'Name', value: '' },\n { key: 'phone', type: 'input', label: 'Phone', value: '' }\n];\n\n// Add PRIMITIVE item to end of array\naddTag() {\n this.eventBus.dispatch(arrayEvent('tags').append(this.tagTemplate));\n}\n\n// Add OBJECT item to end of array\naddContact() {\n this.eventBus.dispatch(arrayEvent('contacts').append(this.contactTemplate));\n}\n\n// Add item to beginning of array\nprependItem() {\n this.eventBus.dispatch(arrayEvent('contacts').prepend(this.contactTemplate));\n}\n\n// Add item at specific index\naddItemAt(index: number) {\n this.eventBus.dispatch(arrayEvent('contacts').insertAt(index, this.contactTemplate));\n}\n\n// Remove last item (no template needed)\nremoveLastItem() {\n this.eventBus.dispatch(arrayEvent('contacts').pop());\n}\n\n// Remove first item (no template needed)\nremoveFirstItem() {\n this.eventBus.dispatch(arrayEvent('contacts').shift());\n}\n\n// Remove item at specific index (no template needed)\nremoveItemAt(index: number) {\n this.eventBus.dispatch(arrayEvent('contacts').removeAt(index));\n}\n```\n\n## Use Cases\n\nArrays are ideal for:\n\n- Lists of simple values (tags, categories, keywords)\n- Repeating form sections (multiple addresses, phone numbers)\n- Dynamic collections where items can be added/removed\n- Collection-based data structures where order matters\n\n## Complete Example: Primitive Array with Initial Values\n\nHere's a complete working example of a primitive array field (simple value list) with initial values and declarative add/remove buttons:\n\n```typescript\nimport { FormConfig } from '@ng-forge/dynamic-forms';\n\n// Template for new tags - a row with an input and a remove button\nconst tagTemplate = {\n key: 'tag',\n type: 'row',\n fields: [\n {\n key: 'value',\n type: 'input',\n label: 'Tag',\n required: true,\n minLength: 2,\n },\n {\n key: 'removeTag',\n type: 'removeArrayItem',\n label: 'Remove',\n props: { color: 'warn' },\n },\n ],\n} as const;\n\nconst formConfig = {\n fields: [\n {\n key: 'tags',\n type: 'array',\n // Start with two initial tags using the same row structure\n fields: [\n [\n {\n key: 'tag',\n type: 'row',\n fields: [\n { key: 'value', type: 'input', label: 'Tag', value: 'featured', required: true, minLength: 2 },\n { key: 'removeTag', type: 'removeArrayItem', label: 'Remove', props: { color: 'warn' } },\n ],\n },\n ],\n [\n {\n key: 'tag',\n type: 'row',\n fields: [\n { key: 'value', type: 'input', label: 'Tag', value: 'popular', required: true, minLength: 2 },\n { key: 'removeTag', type: 'removeArrayItem', label: 'Remove', props: { color: 'warn' } },\n ],\n },\n ],\n ],\n },\n // Declarative add button - no EventBus needed\n {\n key: 'addTag',\n type: 'addArrayItem',\n label: 'Add Tag',\n arrayKey: 'tags',\n template: [tagTemplate],\n props: { color: 'primary' },\n },\n ],\n} as const satisfies FormConfig;\n\n// Form value: { tags: [{ tag: { value: 'featured' } }, { tag: { value: 'popular' } }] }\n```\n\n## Complete Example: Object Array with Initial Values\n\nHere's a complete working example of an object array field with initial values, validation, and declarative buttons:\n\n```typescript\nimport { FormConfig } from '@ng-forge/dynamic-forms';\n\n// Template for new contacts - includes a remove button inside each item\nconst contactTemplate = [\n { key: 'name', type: 'input', label: 'Contact Name', required: true, minLength: 2 },\n { key: 'phone', type: 'input', label: 'Phone Number', required: true, pattern: /^\\d{10}$/ },\n {\n key: 'relationship',\n type: 'select',\n label: 'Relationship',\n options: [\n { label: 'Family', value: 'family' },\n { label: 'Friend', value: 'friend' },\n { label: 'Colleague', value: 'colleague' },\n ],\n },\n {\n key: 'removeContact',\n type: 'removeArrayItem',\n label: 'Remove Contact',\n props: { color: 'warn' },\n },\n] as const;\n\nconst formConfig = {\n fields: [\n {\n key: 'contacts',\n type: 'array',\n // Start with one pre-filled contact (includes remove button)\n fields: [\n [\n { key: 'name', type: 'input', label: 'Contact Name', value: 'John Doe', required: true, minLength: 2 },\n {\n key: 'phone',\n type: 'input',\n label: 'Phone Number',\n value: '5551234567',\n required: true,\n pattern: /^\\d{10}$/,\n },\n {\n key: 'relationship',\n type: 'select',\n label: 'Relationship',\n value: 'family',\n options: [\n { label: 'Family', value: 'family' },\n { label: 'Friend', value: 'friend' },\n { label: 'Colleague', value: 'colleague' },\n ],\n },\n {\n key: 'removeContact',\n type: 'removeArrayItem',\n label: 'Remove Contact',\n props: { color: 'warn' },\n },\n ],\n ],\n },\n // Declarative add button - no EventBus needed\n {\n key: 'addContact',\n type: 'addArrayItem',\n label: 'Add Contact',\n arrayKey: 'contacts',\n template: contactTemplate,\n props: { color: 'primary' },\n },\n ],\n} as const satisfies FormConfig;\n\n// Form value: { contacts: [{ name: 'John Doe', phone: '5551234567', relationship: 'family' }] }\n```\n\n## Template Requirement\n\n**Important:** When adding items dynamically (via buttons or event bus), you must always provide an explicit `template`. There is no automatic fallback to use the first item's structure.\n\nThis design ensures:\n\n- Clear intent - each add operation explicitly defines what to add\n- Flexibility - different buttons can add different item structures\n- No ambiguity - the array's `fields` only defines initial items, not a \"default template\"\n\n## Heterogeneous Items\n\nArrays can have items with different field structures:\n\n```typescript\n{\n key: 'entries',\n type: 'array',\n fields: [\n // Item 0: Simple tag\n [{ key: 'tag', type: 'input', label: 'Tag', value: 'simple' }],\n // Item 1: Contact with more fields\n [\n { key: 'name', type: 'input', label: 'Name', value: 'Alice' },\n { key: 'email', type: 'input', label: 'Email', value: 'alice@example.com' }\n ]\n ]\n}\n```\n\nThis flexibility allows each item to have its own structure, though typically all items share the same structure for consistency.\n\n## Nesting Constraints\n\nArray fields can be used within:\n\n- Pages (top-level container)\n- Rows (for horizontal layouts)\n- Groups (for nested arrays within objects)\n\nArrays **cannot** contain:\n\n- Other array fields (no nested arrays)\n- Page fields\n\n## Allowed Children\n\nArrays can contain these field types:\n\n- Leaf fields (input, select, checkbox, etc.) -> creates flat or object arrays\n- Group fields with key -> creates nested object arrays `[{groupKey: {...}}, ...]`\n- Row fields -> creates flat object arrays `[{...}, {...}]` (rows don't add nesting)\n- Button fields (for remove operations inside each item)\n\nSee [Type Safety & Inference](../advanced/type-safety/basics) for details on how arrays affect type inference.\n\n## Conditional Visibility\n\nArray containers support the `logic` property to conditionally show or hide the entire array section based on form state.\n\n```typescript\n{\n key: 'dependents',\n type: 'array',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'hasDependents',\n operator: 'equals',\n value: false,\n },\n }],\n fields: [/* ... */],\n}\n```\n\nWhen the array is hidden, all items and add/remove buttons are hidden with it. Only `'hidden'` is supported as a logic type on containers.\n\nFor all available condition types and operators, see [Conditional Logic](../../../dynamic-behavior/conditional-logic/overview/).\n\n## When to Use the Complete API\n\nThe complete API documented on this page is the right choice when you need capabilities beyond what the simplified API offers:\n\n- **Heterogeneous items** -- Different field structures per item (e.g. some items have 2 fields, others have 5)\n- **Custom button placement** -- Positioning add/remove/prepend/insert buttons in specific locations relative to the array\n- **Programmatic control via EventBus** -- Dispatching `arrayEvent` commands for append, prepend, insertAt, pop, shift, and removeAt operations from component code\n- **Custom templates per button action** -- Different add buttons that each insert a different item structure (e.g. an \"Add Simple\" and an \"Add Detailed\" button)\n- **Mixed primitive and object items** -- Arrays containing both primitive values and object values in the same collection\n\nFor all other scenarios, the [simplified array API](/prebuilt/form-arrays/simplified) provides a more concise configuration with auto-generated buttons."
|
|
130
166
|
},
|
|
131
167
|
{
|
|
132
|
-
id: "form-arrays",
|
|
133
|
-
title: "Form Arrays",
|
|
168
|
+
id: "form-arrays-simplified",
|
|
169
|
+
title: "Form Arrays Simplified",
|
|
134
170
|
category: "prebuilt",
|
|
135
|
-
content: "Arrays create dynamic collections of field values. The fields array defines a **template** that is cloned for each array item.\n\n## Interactive Demo\n\n<iframe src=\"http://localhost:4201/#/examples/array-field\" class=\"example-frame\" title=\"Array Field Demo\"></iframe>\n\n## Flat Arrays (Primitive Values)\n\nFor simple arrays of primitive values, use a leaf field as the template:\n\n```typescript\n{\n key: 'tags',\n type: 'array',\n fields: [\n { key: 'tag', type: 'input', label: 'Tag', value: '' }\n ],\n}\n```\n\nThis creates a flat array in the form value:\n\n```typescript\n{\n tags: ['tag1', 'tag2', 'tag3'];\n}\n```\n\n## Object Arrays (Nested Groups)\n\nFor arrays of objects, use a group field as the template:\n\n```typescript\n{\n key: 'contacts',\n type: 'array',\n fields: [{\n type: 'group',\n fields: [\n { key: 'name', type: 'input', label: 'Name', value: '' },\n { key: 'phone', type: 'input', label: 'Phone', value: '' }\n ]\n }],\n}\n```\n\nThis creates an array of objects:\n\n```typescript\n{\n contacts: [\n { name: 'John Doe', phone: '5551234567' },\n { name: 'Jane Smith', phone: '5559876543' },\n ];\n}\n```\n\n## Array vs Group\n\n- **Groups** create nested objects with keys: `{ address: { street: '', city: '' } }`\n- **Flat Arrays** create lists of values: `{ tags: ['value1', 'value2'] }`\n- **Object Arrays** create lists of objects: `{ items: [{name: ''}, {name: ''}] }`\n\n## Dynamic Add/Remove\n\nArray items can be added or removed dynamically at runtime using the event bus:\n\n```typescript\nimport { EventBus, AddArrayItemEvent, RemoveArrayItemEvent } from '@ng-forge/dynamic-forms';\n\n// Inject the event bus\neventBus = inject(EventBus);\n\n// Add item to array\naddItem() {\n this.eventBus.dispatch(AddArrayItemEvent, 'tags');\n}\n\n// Add item at specific index\naddItemAt(index: number) {\n this.eventBus.dispatch(AddArrayItemEvent, 'tags', index);\n}\n\n// Remove last item\nremoveItem() {\n this.eventBus.dispatch(RemoveArrayItemEvent, 'tags');\n}\n\n// Remove item at specific index\nremoveItemAt(index: number) {\n this.eventBus.dispatch(RemoveArrayItemEvent, 'tags', index);\n}\n```\n\n## Use Cases\n\nArrays are ideal for:\n\n- Lists of simple values (tags, categories, keywords)\n- Repeating form sections (multiple addresses, phone numbers)\n- Dynamic collections where items can be added/removed\n- Collection-based data structures where order matters\n\n## Complete Example: Flat Array\n\nHere's a complete working example of a flat array field with dynamic add/remove:\n\n```typescript\nimport { Component, inject } from '@angular/core';\nimport { DynamicForm, EventBus, AddArrayItemEvent, RemoveArrayItemEvent } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-tags-form',\n imports: [DynamicForm],\n template: `\n <form [dynamic-form]=\"formConfig\"></form>\n <button (click)=\"addTag()\">Add Tag</button>\n `,\n})\nexport class TagsFormComponent {\n private eventBus = inject(EventBus);\n\n formConfig = {\n fields: [\n // Note: Array fields don't support the 'label' property.\n // Use a text field above if you need a section header.\n {\n key: 'tags',\n type: 'array',\n fields: [\n {\n key: 'tag',\n type: 'input',\n label: 'Tag',\n value: '',\n required: true,\n minLength: 2,\n },\n ],\n },\n ],\n };\n\n addTag() {\n this.eventBus.dispatch(AddArrayItemEvent, 'tags');\n }\n\n removeTag(index: number) {\n this.eventBus.dispatch(RemoveArrayItemEvent, 'tags', index);\n }\n}\n```\n\n## Complete Example: Object Array\n\nHere's a complete working example of an object array field with validation:\n\n```typescript\nimport { Component, inject } from '@angular/core';\nimport { DynamicForm, EventBus, AddArrayItemEvent, RemoveArrayItemEvent } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-contacts-form',\n imports: [DynamicForm],\n template: `\n <form [dynamic-form]=\"formConfig\"></form>\n <button (click)=\"addContact()\">Add Contact</button>\n `,\n})\nexport class ContactsFormComponent {\n private eventBus = inject(EventBus);\n\n formConfig = {\n fields: [\n // Note: Array fields don't support the 'label' property.\n // Use a text field above if you need a section header.\n {\n key: 'contacts',\n type: 'array',\n fields: [\n {\n type: 'group',\n fields: [\n {\n key: 'name',\n type: 'input',\n label: 'Contact Name',\n value: '',\n required: true,\n minLength: 2,\n },\n {\n key: 'phone',\n type: 'input',\n label: 'Phone Number',\n value: '',\n required: true,\n pattern: /^\\d{10}$/,\n },\n {\n key: 'relationship',\n type: 'select',\n label: 'Relationship',\n value: 'friend',\n options: [\n { label: 'Family', value: 'family' },\n { label: 'Friend', value: 'friend' },\n { label: 'Colleague', value: 'colleague' },\n ],\n },\n ],\n },\n ],\n },\n ],\n };\n\n addContact() {\n this.eventBus.dispatch(AddArrayItemEvent, 'contacts');\n }\n\n removeContact(index: number) {\n this.eventBus.dispatch(RemoveArrayItemEvent, 'contacts', index);\n }\n}\n```\n\n## Template Field Concept\n\nThe key concept is that the `fields` array contains a **template** (typically one field definition):\n\n- **Flat arrays**: Template is a leaf field \u2192 creates `[value1, value2]`\n- **Object arrays**: Template is a group field \u2192 creates `[{...}, {...}]`\n\nWhen you add an array item via the event bus, the template is cloned and a new instance is created with the appropriate default value.\n\n## Nesting Constraints\n\nArray fields can be used within:\n\n- Pages (top-level container)\n- Rows (for horizontal layouts)\n- Groups (for nested arrays within objects)\n\nArrays **cannot** contain:\n\n- Other array fields (no nested arrays)\n- Page fields\n\n## Allowed Children (as template)\n\nArrays can use these field types as templates:\n\n- Leaf fields (input, select, checkbox, etc.) \u2192 creates flat arrays\n- Group fields \u2192 creates object arrays\n- Row fields \u2192 for horizontal layouts within each array item\n\nSee [Type Safety & Inference](../advanced/type-safety/basics) for details on how arrays affect type inference."
|
|
171
|
+
content: "The simplified array API provides a concise way to define dynamic arrays. Instead of manually specifying each item in `fields`, you provide a `template` for the item structure and a `value` array with initial data. Add and remove buttons are generated automatically.\n\n> **When should I use this?** For most array use cases -- primitive lists, object lists, and standard add/remove workflows -- the simplified API is the right choice. For advanced scenarios like heterogeneous items or custom button placement, see [Form Arrays (Complete)](/prebuilt/form-arrays/complete).\n\n## Interactive Demo\n\n<iframe src=\"http://localhost:4201/#/examples/simplified-array\" class=\"example-frame\" title=\"Simplified Array Demo\"></iframe>\n\n## Overview\n\nThe simplified API uses two key properties:\n\n| Property | Description |\n| ----------- | -------------------------------------------------------------------------- |\n| `template` | Defines the structure of a single array item (single field or field array) |\n| `value` | Initial data array -- each element creates one pre-filled item |\n| `minLength` | Minimum number of array items (form invalid if fewer) |\n| `maxLength` | Maximum number of array items (form invalid if more) |\n\nThe `template` shape determines the array variant:\n\n- **Single field** (`template: { ... }`) -- primitive array (e.g. `['tag1', 'tag2']`)\n- **Array of fields** (`template: [{ ... }, { ... }]`) -- object array (e.g. `[{ name: 'Jane', phone: '555' }]`)\n\n```typescript\n// Discriminant: template is a single field \u2192 primitive array\n{ key: 'tags', type: 'array', template: { key: 'value', type: 'input', label: 'Tag' } }\n\n// Discriminant: template is an array of fields \u2192 object array\n{ key: 'contacts', type: 'array', template: [\n { key: 'name', type: 'input', label: 'Name' },\n { key: 'phone', type: 'input', label: 'Phone' },\n] }\n```\n\n## Primitive Arrays\n\nFor simple arrays of scalar values like `['angular', 'typescript']`, provide a single field as the `template` and a flat array as the `value`:\n\n```typescript\n{\n key: 'tags',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Tag', required: true },\n value: ['angular', 'typescript'],\n}\n```\n\nThis renders two input fields pre-filled with `'angular'` and `'typescript'`, each with an auto-generated **Remove** button. An **Add** button is placed after the array.\n\nForm value output:\n\n```typescript\n{\n tags: ['angular', 'typescript'];\n}\n```\n\n## Object Arrays\n\nFor arrays of objects like `[{ name: 'Jane', phone: '555' }]`, provide an array of fields as the `template` and an array of objects as the `value`:\n\n```typescript\n{\n key: 'contacts',\n type: 'array',\n template: [\n { key: 'name', type: 'input', label: 'Contact Name', required: true },\n { key: 'phone', type: 'input', label: 'Phone Number' },\n ],\n value: [\n { name: 'Jane', phone: '555-1234' },\n { name: 'Bob', phone: '555-5678' },\n ],\n}\n```\n\nEach value object is matched to the template by key. If a value object omits a key, the corresponding field renders without an initial value.\n\nForm value output:\n\n```typescript\n{\n contacts: [\n { name: 'Jane', phone: '555-1234' },\n { name: 'Bob', phone: '555-5678' },\n ];\n}\n```\n\n## Empty Arrays\n\nOmit `value` or set `value: []` to start with an empty array. Users populate it via the auto-generated **Add** button:\n\n```typescript\n// No value property \u2014 starts empty\n{\n key: 'tags',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Tag' },\n}\n\n// Explicit empty array \u2014 same behavior\n{\n key: 'emails',\n type: 'array',\n template: [\n { key: 'address', type: 'input', label: 'Email Address', required: true },\n { key: 'label', type: 'select', label: 'Type', options: [\n { label: 'Work', value: 'work' },\n { label: 'Personal', value: 'personal' },\n ] },\n ],\n value: [],\n}\n```\n\n## Button Customization\n\nBy default, the library generates an **Add** button (label: `\"Add\"`) after the array and a **Remove** button (label: `\"Remove\"`) inside each item. You can customize both via `addButton` and `removeButton`:\n\n```typescript\n{\n key: 'tags',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Tag' },\n value: ['angular'],\n addButton: { label: 'Add Tag', props: { color: 'primary' } },\n removeButton: { label: 'Delete', props: { color: 'warn' } },\n}\n```\n\n### Button Config Properties\n\n| Property | Type | Description |\n| -------- | ------------------------- | ---------------------------------------------------- |\n| `label` | `string` | Custom label text for the button |\n| `props` | `Record<string, unknown>` | Additional properties passed to the button component |\n\n## Button Opt-Out\n\nSet `addButton: false` or `removeButton: false` to suppress the corresponding auto-generated button entirely:\n\n```typescript\n// No add button \u2014 items are only removable, not addable\n{\n key: 'defaultTags',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Tag' },\n value: ['featured', 'popular'],\n addButton: false,\n}\n\n// No remove button \u2014 items are only addable, not removable\n{\n key: 'skills',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Skill' },\n value: ['TypeScript'],\n removeButton: false,\n}\n\n// No buttons at all \u2014 static list rendered from initial values\n{\n key: 'readonlyItems',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Item' },\n value: ['A', 'B', 'C'],\n addButton: false,\n removeButton: false,\n}\n```\n\nWhen `removeButton: false` is set for a primitive array, each item renders as a plain field without being wrapped in a row. When set for an object array, the remove button is simply omitted from the item's field list.\n\n## Complete Example: Primitive Array\n\nA full working configuration for a tag list with custom buttons:\n\n```typescript\nimport { FormConfig } from '@ng-forge/dynamic-forms';\n\nconst formConfig: FormConfig = {\n fields: [\n {\n key: 'projectName',\n type: 'input',\n label: 'Project Name',\n required: true,\n },\n {\n key: 'tags',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Tag', required: true, minLength: 2 },\n value: ['angular', 'open-source'],\n addButton: { label: 'Add Tag' },\n removeButton: { label: 'Remove Tag' },\n },\n ],\n};\n\n// Form value:\n// {\n// projectName: 'My Project',\n// tags: ['angular', 'open-source']\n// }\n```\n\n## Complete Example: Object Array\n\nA full working configuration for a contact list:\n\n```typescript\nimport { FormConfig } from '@ng-forge/dynamic-forms';\n\nconst formConfig: FormConfig = {\n fields: [\n {\n key: 'teamName',\n type: 'input',\n label: 'Team Name',\n required: true,\n },\n {\n key: 'members',\n type: 'array',\n template: [\n { key: 'name', type: 'input', label: 'Full Name', required: true },\n { key: 'email', type: 'input', label: 'Email', required: true },\n {\n key: 'role',\n type: 'select',\n label: 'Role',\n options: [\n { label: 'Developer', value: 'dev' },\n { label: 'Designer', value: 'design' },\n { label: 'Manager', value: 'manager' },\n ],\n },\n ],\n value: [\n { name: 'Alice', email: 'alice@example.com', role: 'dev' },\n { name: 'Bob', email: 'bob@example.com', role: 'design' },\n ],\n addButton: { label: 'Add Member' },\n removeButton: { label: 'Remove' },\n },\n ],\n};\n\n// Form value:\n// {\n// teamName: 'Frontend Team',\n// members: [\n// { name: 'Alice', email: 'alice@example.com', role: 'dev' },\n// { name: 'Bob', email: 'bob@example.com', role: 'design' },\n// ]\n// }\n```\n\n## Form Value Output\n\nThe simplified API produces the same form values as the complete API:\n\n| Template Shape | Value Shape | Output |\n| --------------- | --------------------- | ------------------------------------------------ |\n| Single field | Flat array of scalars | `{ tags: ['angular', 'typescript'] }` |\n| Array of fields | Array of objects | `{ contacts: [{ name: 'Jane', phone: '555' }] }` |\n| Any template | `[]` or omitted | `{ items: [] }` |\n\n## Array Size Validation\n\nUse `minLength` and `maxLength` to constrain the number of items. When violated, the form becomes invalid (e.g. submit button is disabled).\n\n```typescript\n{\n key: 'tags',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Tag' },\n value: ['angular'],\n minLength: 1,\n maxLength: 5,\n}\n```\n\nBoth properties are optional and can be used independently or together.\n\n## Conditional Visibility\n\nSimplified arrays support `logic` for conditional visibility, just like the complete API:\n\n```typescript\n{\n key: 'extras',\n type: 'array',\n template: { key: 'value', type: 'input', label: 'Extra' },\n value: [],\n logic: [{ type: 'hidden', condition: { type: 'fieldValue', fieldPath: 'showExtras', operator: 'equals', value: false } }],\n}\n```\n\n## Decision Guide: Simplified vs Complete API\n\n| Scenario | Recommended API |\n| ----------------------------------------------- | --------------- |\n| Uniform items with standard add/remove | **Simplified** |\n| Pre-filled values from a data source | **Simplified** |\n| Custom button labels or styling | **Simplified** |\n| Empty array populated by users | **Simplified** |\n| Static list (no add/remove buttons) | **Simplified** |\n| Heterogeneous items (different fields per item) | Complete |\n| Custom button placement (buttons outside array) | Complete |\n| Programmatic control via EventBus | Complete |\n| Multiple add buttons (append, prepend, insert) | Complete |\n| Nested arrays inside item templates | Complete |\n\nAs a rule of thumb: **start with the simplified API**. If you hit a limitation, switch to the [complete API](/prebuilt/form-arrays/complete)."
|
|
136
172
|
},
|
|
137
173
|
{
|
|
138
174
|
id: "form-groups",
|
|
139
175
|
title: "Form Groups",
|
|
140
176
|
category: "prebuilt",
|
|
141
|
-
content: "Groups nest form fields under a single key in the form value. This creates logical grouping for form data, not visual grouping.\n\n## Interactive Demo\n\n<iframe src=\"http://localhost:4201/#/examples/group
|
|
177
|
+
content: "Groups nest form fields under a single key in the form value. This creates logical grouping for form data, not visual grouping.\n\n## Interactive Demo\n\n<iframe src=\"http://localhost:4201/#/examples/group\" class=\"example-frame\" title=\"Group Field Demo\"></iframe>\n\n## Basic Group\n\n```typescript\n{\n type: 'group',\n key: 'address',\n fields: [\n { key: 'street', type: 'input', label: 'Street', value: '' },\n { key: 'city', type: 'input', label: 'City', value: '' },\n { key: 'zip', type: 'input', label: 'ZIP', value: '' },\n ],\n}\n```\n\nThis creates a nested structure in the form value:\n\n```typescript\n{\n address: {\n street: '',\n city: '',\n zip: ''\n }\n}\n```\n\nGroups are for organizing form **data**, not UI. The visual presentation depends on your UI integration (Material, Bootstrap, etc.).\n\n## Complete Example\n\nHere's a complete working example of a group field with validation:\n\n```typescript\nimport { Component } from '@angular/core';\nimport { DynamicForm } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-user-profile-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"formConfig\"></form>`,\n})\nexport class UserProfileFormComponent {\n formConfig = {\n fields: [\n {\n key: 'name',\n type: 'input',\n label: 'Full Name',\n value: '',\n required: true,\n },\n {\n key: 'address',\n type: 'group',\n fields: [\n {\n key: 'street',\n type: 'input',\n label: 'Street Address',\n value: '',\n required: true,\n },\n {\n key: 'city',\n type: 'input',\n label: 'City',\n value: '',\n required: true,\n },\n {\n key: 'state',\n type: 'input',\n label: 'State',\n value: '',\n required: true,\n maxLength: 2,\n },\n {\n key: 'zip',\n type: 'input',\n label: 'ZIP Code',\n value: '',\n required: true,\n pattern: /^\\d{5}$/,\n },\n ],\n },\n ],\n };\n}\n```\n\nThis produces a form value with nested structure:\n\n```typescript\n{\n name: 'John Doe',\n address: {\n street: '123 Main St',\n city: 'Springfield',\n state: 'IL',\n zip: '62701'\n }\n}\n```\n\n## Nesting Restrictions\n\nGroup fields can be used within:\n\n- Pages (top-level container)\n- Rows (for horizontal layouts)\n- Array fields (for creating object arrays where each array item is an object)\n\nGroups **cannot** be nested inside:\n\n- Other group fields (no nested groups)\n\n## Allowed Children\n\nGroups can contain:\n\n- Leaf fields (input, select, checkbox, etc.)\n- Row fields (for horizontal layouts within the group)\n\nSee [Type Safety & Inference](../advanced/type-safety/basics) for details on how groups affect type inference.\n\n## Conditional Visibility\n\nGroup containers support the `logic` property to conditionally show or hide the entire group (and all its nested fields) based on form state.\n\n```typescript\n{\n key: 'addressGroup',\n type: 'group',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'sameAsBilling',\n operator: 'equals',\n value: true,\n },\n }],\n fields: [\n { key: 'street', type: 'input', label: 'Street', value: '' },\n { key: 'city', type: 'input', label: 'City', value: '' },\n { key: 'zip', type: 'input', label: 'ZIP', value: '' },\n ],\n}\n```\n\nWhen the group is hidden, all its nested fields are hidden with it. Only `'hidden'` is supported as a logic type on containers (not `required`, `readonly`, or `disabled`).\n\nFor all available condition types and operators, see [Conditional Logic](../../dynamic-behavior/conditional-logic/overview/)."
|
|
142
178
|
},
|
|
143
179
|
{
|
|
144
180
|
id: "form-pages",
|
|
145
181
|
title: "Form Pages",
|
|
146
182
|
category: "prebuilt",
|
|
147
|
-
content: "Create multi-step forms by using page fields. When your form contains page fields, it automatically enters \"paged mode\" and renders with navigation controls.\n\n## Basic Multi-Step Form\n\nCreate a multi-step form by adding multiple page fields to your form configuration:\n\n```typescript\n{\n fields: [\n {\n key: 'account',\n type: 'page',\n fields: [\n { key: 'accountTitle', type: 'text', label: 'Account Information', props: { elementType: 'h3' } },\n { key: 'username', type: 'input', label: 'Username', value: '', required: true },\n { key: 'password', type: 'input', label: 'Password', value: '', props: { type: 'password' }, required: true },\n ],\n },\n {\n key: 'profile',\n type: 'page',\n fields: [\n { key: 'profileTitle', type: 'text', label: 'Profile Details', props: { elementType: 'h3' } },\n { key: 'firstName', type: 'input', label: 'First Name', value: '' },\n { key: 'lastName', type: 'input', label: 'Last Name', value: '' },\n ],\n },\n ],\n}\n```\n\n## Page Properties\n\nEach page field supports:\n\n- `key` (required) - Unique identifier for the page\n- `type: 'page'` (required) - Field type identifier\n- `fields` (required) - Array of child fields to render on this page\n\n## Page with Description\n\n```typescript\n{\n key: 'preferences',\n type: 'page',\n fields: [\n { key: 'newsletter', type: 'checkbox', label: 'Subscribe to newsletter', value: false },\n { key: 'notifications', type: 'checkbox', label: 'Enable notifications', value: false },\n ],\n}\n```\n\n## Paged Mode Behavior\n\nWhen your form contains page fields:\n\n- **Automatic Detection**: Form automatically enters \"paged mode\"\n- **Navigation Controls**: Previous/Next buttons are rendered automatically\n- **Validation**: Users must complete required fields before advancing to the next page\n- **Single Page View**: Only one page is visible at a time\n\n## Performance & Lazy Loading\n\nng-forge uses Angular's `@defer` blocks with smart prefetching to optimize page rendering while maintaining flicker-free navigation.\n\n### How It Works\n\nThe page orchestrator uses a **2-tier loading strategy**:\n\n**Tier 1: Current + Adjacent Pages (\xB11)**\n\n- Render immediately using `@defer (on immediate)`\n- Initially, only 3 pages load (current + 2 adjacent)\n- Adjacent pages are fully rendered but hidden with `display: none`\n- Ensures zero flicker when navigating forward/backward\n\n**Tier 2: Distant Pages (2+ steps away)**\n\n- Defer loading until browser is idle using `@defer (on idle)`\n- Lazy loading optimizes initial page load\n- Load automatically during browser idle time\n- Once loaded, pages remain in DOM (hidden with CSS)\n\n### Benefits\n\n```typescript\n// Example: User is on step 2 of 5\nfields: [\n { key: 'step1', type: 'page', ... }, // \u2713 Rendered (adjacent)\n { key: 'step2', type: 'page', ... }, // \u2713 Visible (current)\n { key: 'step3', type: 'page', ... }, // \u2713 Rendered (adjacent)\n { key: 'step4', type: 'page', ... }, // \u23F3 Deferred (distant)\n { key: 'step5', type: 'page', ... }, // \u23F3 Deferred (distant)\n]\n```\n\n**Performance advantages:**\n\n- \u26A1 **Zero navigation flicker** - Adjacent pages already rendered\n- \u{1F680} **Faster initial load** - Only 3 pages render immediately, distant pages defer until idle\n- \u23F1\uFE0F **Better Time to Interactive (TTI)** - Reduced initial JavaScript parsing/compilation\n- \u{1F4F1} **Mobile-friendly** - Lower startup cost on slower devices\n\n**Note:** Once loaded, pages remain in the DOM (hidden with CSS). The primary benefit is optimizing **initial load performance**, not ongoing memory usage.\n\nThis optimization happens automatically - no configuration needed.\n\n## Value Structure\n\nPages are container fields - they don't add nesting to your form values. Fields flatten to the root level:\n\n```typescript\n// Form config with pages\n{\n fields: [\n {\n key: 'page1',\n type: 'page',\n fields: [\n { key: 'firstName', type: 'input', value: '' },\n ],\n },\n {\n key: 'page2',\n type: 'page',\n fields: [\n { key: 'lastName', type: 'input', value: '' },\n ],\n },\n ],\n}\n\n// Resulting form value (flat structure)\n{\n firstName: 'John',\n lastName: 'Doe',\n // Note: page keys are NOT in the value\n}\n```\n\n## Nesting Restrictions\n\nPage fields can only be used at the top level of your form configuration. They **cannot** be nested inside:\n\n- Other page fields\n- Row fields\n- Group fields\n\nAttempting to nest pages will result in a validation error.\n\n## Allowed Children\n\nPages can contain:\n\n- Leaf fields (input, select, checkbox, etc.)\n- Row fields (for horizontal layouts)\n- Group fields (for nested data structures)\n\n## CSS Classes\n\nPage fields use these classes for styling:\n\n- `.df-page` - Applied to the page container\n- `.df-page-visible` - Applied to the currently visible page\n- `.df-page-hidden` - Applied to hidden pages\n- `.df-page-field` - Applied to the page field component"
|
|
183
|
+
content: "Create multi-step forms by using page fields. When your form contains page fields, it automatically enters \"paged mode\" and renders with navigation controls.\n\n## Basic Multi-Step Form\n\nCreate a multi-step form by adding multiple page fields to your form configuration:\n\n```typescript\n{\n fields: [\n {\n key: 'account',\n type: 'page',\n fields: [\n { key: 'accountTitle', type: 'text', label: 'Account Information', props: { elementType: 'h3' } },\n { key: 'username', type: 'input', label: 'Username', value: '', required: true },\n { key: 'password', type: 'input', label: 'Password', value: '', props: { type: 'password' }, required: true },\n ],\n },\n {\n key: 'profile',\n type: 'page',\n fields: [\n { key: 'profileTitle', type: 'text', label: 'Profile Details', props: { elementType: 'h3' } },\n { key: 'firstName', type: 'input', label: 'First Name', value: '' },\n { key: 'lastName', type: 'input', label: 'Last Name', value: '' },\n ],\n },\n ],\n}\n```\n\n## Page Properties\n\nEach page field supports:\n\n- `key` (required) - Unique identifier for the page\n- `type: 'page'` (required) - Field type identifier\n- `fields` (required) - Array of child fields to render on this page\n\n## Page with Description\n\n```typescript\n{\n key: 'preferences',\n type: 'page',\n fields: [\n { key: 'newsletter', type: 'checkbox', label: 'Subscribe to newsletter', value: false },\n { key: 'notifications', type: 'checkbox', label: 'Enable notifications', value: false },\n ],\n}\n```\n\n## Paged Mode Behavior\n\nWhen your form contains page fields:\n\n- **Automatic Detection**: Form automatically enters \"paged mode\"\n- **Navigation Controls**: Previous/Next buttons are rendered automatically\n- **Validation**: Users must complete required fields before advancing to the next page\n- **Single Page View**: Only one page is visible at a time\n\n## Performance & Lazy Loading\n\nng-forge uses Angular's `@defer` blocks with smart prefetching to optimize page rendering while maintaining flicker-free navigation.\n\n### How It Works\n\nThe page orchestrator uses a **2-tier loading strategy**:\n\n**Tier 1: Current + Adjacent Pages (\xB11)**\n\n- Render immediately using `@defer (on immediate)`\n- Initially, only 3 pages load (current + 2 adjacent)\n- Adjacent pages are fully rendered but hidden with `display: none`\n- Ensures zero flicker when navigating forward/backward\n\n**Tier 2: Distant Pages (2+ steps away)**\n\n- Defer loading until browser is idle using `@defer (on idle)`\n- Lazy loading optimizes initial page load\n- Load automatically during browser idle time\n- Once loaded, pages remain in DOM (hidden with CSS)\n\n### Benefits\n\n```typescript\n// Example: User is on step 2 of 5\nfields: [\n { key: 'step1', type: 'page', ... }, // \u2713 Rendered (adjacent)\n { key: 'step2', type: 'page', ... }, // \u2713 Visible (current)\n { key: 'step3', type: 'page', ... }, // \u2713 Rendered (adjacent)\n { key: 'step4', type: 'page', ... }, // \u23F3 Deferred (distant)\n { key: 'step5', type: 'page', ... }, // \u23F3 Deferred (distant)\n]\n```\n\n**Performance advantages:**\n\n- \u26A1 **Zero navigation flicker** - Adjacent pages already rendered\n- \u{1F680} **Faster initial load** - Only 3 pages render immediately, distant pages defer until idle\n- \u23F1\uFE0F **Better Time to Interactive (TTI)** - Reduced initial JavaScript parsing/compilation\n- \u{1F4F1} **Mobile-friendly** - Lower startup cost on slower devices\n\n**Note:** Once loaded, pages remain in the DOM (hidden with CSS). The primary benefit is optimizing **initial load performance**, not ongoing memory usage.\n\nThis optimization happens automatically - no configuration needed.\n\n## Value Structure\n\nPages are container fields - they don't add nesting to your form values. Fields flatten to the root level:\n\n```typescript\n// Form config with pages\n{\n fields: [\n {\n key: 'page1',\n type: 'page',\n fields: [\n { key: 'firstName', type: 'input', value: '' },\n ],\n },\n {\n key: 'page2',\n type: 'page',\n fields: [\n { key: 'lastName', type: 'input', value: '' },\n ],\n },\n ],\n}\n\n// Resulting form value (flat structure)\n{\n firstName: 'John',\n lastName: 'Doe',\n // Note: page keys are NOT in the value\n}\n```\n\n## Nesting Restrictions\n\nPage fields can only be used at the top level of your form configuration. They **cannot** be nested inside:\n\n- Other page fields\n- Row fields\n- Group fields\n\nAttempting to nest pages will result in a validation error.\n\n## Allowed Children\n\nPages can contain:\n\n- Leaf fields (input, select, checkbox, etc.)\n- Row fields (for horizontal layouts)\n- Group fields (for nested data structures)\n\n## Conditional Visibility\n\nPages support the `logic` property to conditionally skip a page (hide it from the page navigation and progression) based on form state.\n\n```typescript\n{\n key: 'businessDetails',\n type: 'page',\n label: 'Business Details',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'notEquals',\n value: 'business',\n },\n }],\n fields: [\n { key: 'companyName', type: 'input', label: 'Company Name', value: '' },\n { key: 'taxId', type: 'input', label: 'Tax ID', value: '' },\n ],\n}\n```\n\nWhen a page is hidden, it is excluded from the multi-step navigation \u2014 users skip directly past it. Only `'hidden'` is supported as a logic type on containers.\n\nFor all available condition types and operators, see [Conditional Logic](../../dynamic-behavior/conditional-logic/overview/).\n\n## CSS Classes\n\nPage fields use these classes for styling:\n\n- `.df-page` - Applied to the page container\n- `.df-page-visible` - Applied to the currently visible page\n- `.df-page-hidden` - Applied to hidden pages\n- `.df-page-field` - Applied to the page field component"
|
|
148
184
|
},
|
|
149
185
|
{
|
|
150
186
|
id: "form-rows",
|
|
151
187
|
title: "Form Rows",
|
|
152
188
|
category: "prebuilt",
|
|
153
|
-
content: "Organize fields into horizontal rows for compact layouts. Rows display fields side-by-side.\n\n## Interactive Demo\n\n<iframe src=\"http://localhost:4201/#/examples/row
|
|
189
|
+
content: "Organize fields into horizontal rows for compact layouts. Rows display fields side-by-side.\n\n## Interactive Demo\n\n<iframe src=\"http://localhost:4201/#/examples/row\" class=\"example-frame\" title=\"Row Field Demo\"></iframe>\n\n## Basic Row\n\n```typescript\n{\n type: 'row',\n fields: [\n { key: 'firstName', type: 'input', label: 'First Name', value: '', col: 6 },\n { key: 'lastName', type: 'input', label: 'Last Name', value: '', col: 6 },\n ],\n}\n```\n\nRows flatten their children - they don't add nesting to form values. Use the `col` property to control field widths (see Column Sizing below).\n\n## Column Sizing\n\nControl field widths within rows using the `col` property:\n\n```typescript\n{\n type: 'row',\n fields: [\n { key: 'city', type: 'input', label: 'City', value: '', col: 6 },\n { key: 'state', type: 'select', label: 'State', value: '', col: 3 },\n { key: 'zip', type: 'input', label: 'ZIP', value: '', col: 3 },\n ],\n}\n```\n\nThe `col` property uses a 12-column grid system (like Bootstrap). In this example:\n\n- `city` takes 6/12 (50%) width\n- `state` takes 3/12 (25%) width\n- `zip` takes 3/12 (25%) width\n\n## Responsive Behavior\n\nRows automatically stack on small screens, making forms mobile-friendly without additional configuration.\n\n## Complete Example\n\nHere's a complete working example of a row field with multiple fields:\n\n```typescript\nimport { Component } from '@angular/core';\nimport { DynamicForm } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-address-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"formConfig\"></form>\n >`,\n})\nexport class AddressFormComponent {\n formConfig = {\n fields: [\n {\n type: 'row',\n fields: [\n {\n key: 'firstName',\n type: 'input',\n label: 'First Name',\n value: '',\n required: true,\n col: 6,\n },\n {\n key: 'lastName',\n type: 'input',\n label: 'Last Name',\n value: '',\n required: true,\n col: 6,\n },\n ],\n },\n {\n key: 'email',\n type: 'input',\n label: 'Email Address',\n value: '',\n required: true,\n email: true,\n },\n {\n type: 'row',\n fields: [\n {\n key: 'city',\n type: 'input',\n label: 'City',\n value: '',\n required: true,\n col: 6,\n },\n {\n key: 'state',\n type: 'input',\n label: 'State',\n value: '',\n required: true,\n maxLength: 2,\n col: 3,\n },\n {\n key: 'zip',\n type: 'input',\n label: 'ZIP',\n value: '',\n required: true,\n pattern: /^\\d{5}$/,\n col: 3,\n },\n ],\n },\n ],\n };\n}\n```\n\nThis produces a flat form value (rows don't create nesting):\n\n```typescript\n{\n firstName: 'John',\n lastName: 'Doe',\n email: 'john.doe@example.com',\n city: 'Springfield',\n state: 'IL',\n zip: '62701'\n}\n```\n\n## Value Structure\n\nRows are layout containers - they don't add nesting to your form values. Fields flatten to the root level:\n\n```typescript\n// Form config with rows\n{\n fields: [\n {\n type: 'row',\n fields: [\n { key: 'firstName', type: 'input', value: '', col: 6 },\n { key: 'lastName', type: 'input', value: '', col: 6 },\n ],\n },\n ];\n}\n\n// Resulting form value (flat structure)\n{\n firstName: 'John',\n lastName: 'Doe',\n // Note: row itself is NOT in the value\n}\n```\n\n## Nesting Restrictions\n\nRow fields can be used within:\n\n- Pages (top-level container)\n- Groups (for layouts within grouped data)\n- Arrays (for layouts within array items)\n\nRows **cannot** be nested inside:\n\n- Other row fields\n\n## Allowed Children\n\nRows can contain:\n\n- Leaf fields (input, select, checkbox, etc.)\n- Group fields (for nested data structures within the row)\n- Array fields (for repeating sections within the row)\n\n## Conditional Visibility\n\nRow containers support the `logic` property to conditionally show or hide the entire row (and all its fields) based on form state.\n\n```typescript\n{\n key: 'contactRow',\n type: 'row',\n logic: [{\n type: 'hidden',\n condition: {\n type: 'fieldValue',\n fieldPath: 'contactPreference',\n operator: 'equals',\n value: 'none',\n },\n }],\n fields: [\n { key: 'phone', type: 'input', label: 'Phone', col: 6 },\n { key: 'email', type: 'input', label: 'Email', col: 6 },\n ],\n}\n```\n\nOnly `'hidden'` is supported as a logic type on containers. For all available condition types and operators, see [Conditional Logic](../../dynamic-behavior/conditional-logic/overview/)."
|
|
154
190
|
},
|
|
155
191
|
{
|
|
156
192
|
id: "hidden-fields",
|
|
157
193
|
title: "Hidden Fields",
|
|
158
194
|
category: "prebuilt",
|
|
159
|
-
content: "Hidden fields store values in the form model without rendering any visible UI. They're useful for persisting IDs, metadata, or other non-user-facing data.\n\n## Basic Usage\n\n```typescript\n{\n type: 'hidden',\n key: 'id',\n value: 'uuid-550e8400-e29b-41d4-a716-446655440000',\n}\n```\n\nThis adds `id` to the form value without rendering anything:\n\n```typescript\n{\n id: 'uuid-550e8400-e29b-41d4-a716-446655440000',\n // ... other fields\n}\n```\n\n## Supported Value Types\n\nHidden fields support scalar values and arrays of scalars:\n\n```typescript\n// String\n{ type: 'hidden', key: 'sessionId', value: 'abc123' }\n\n// Number\n{ type: 'hidden', key: 'version', value: 42 }\n\n// Boolean\n{ type: 'hidden', key: 'isActive', value: true }\n\n// String array\n{ type: 'hidden', key: 'tags', value: ['draft', 'review'] }\n\n// Number array\n{ type: 'hidden', key: 'tagIds', value: [1, 2, 3] }\n\n// Boolean array\n{ type: 'hidden', key: 'flags', value: [true, false, true] }\n```\n\n## Complete Example\n\nHere's a form that uses hidden fields to persist metadata during an update operation:\n\n```typescript\nimport { Component } from '@angular/core';\nimport { DynamicForm } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-edit-user-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"formConfig\"></form>`,\n})\nexport class EditUserFormComponent {\n formConfig = {\n fields: [\n // Hidden fields for metadata\n {\n type: 'hidden',\n key: 'id',\n value: 'user-123',\n },\n {\n type: 'hidden',\n key: 'version',\n value: 3,\n },\n {\n type: 'hidden',\n key: 'roles',\n value: ['admin', 'editor'],\n },\n // Visible fields\n {\n key: 'name',\n type: 'input',\n label: 'Full Name',\n value: 'John Doe',\n required: true,\n },\n {\n key: 'email',\n type: 'input',\n label: 'Email',\n value: 'john@example.com',\n required: true,\n props: { type: 'email' },\n },\n {\n key: 'submit',\n type: 'submit',\n label: 'Save Changes',\n },\n ],\n };\n}\n```\n\nForm value on submission:\n\n```typescript\n{\n id: 'user-123',\n version: 3,\n roles: ['admin', 'editor'],\n name: 'John Doe',\n email: 'john@example.com'\n}\n```\n\n## Use Cases\n\n- Persisting record IDs for update operations\n- Tracking version numbers for optimistic concurrency\n- Passing context data (parent IDs, source references)\n- Storing computed values that shouldn't be user-editable\n- Including metadata in form submissions\n\n## Placement\n\nHidden fields can be placed:\n\n- At the top level of a form\n- Inside page fields\n- Inside group fields\n\n**Note:** Placing hidden fields inside row fields will generate a validation warning. Rows are meant for horizontal layouts, and since hidden fields don't render anything, placing them in rows serves no purpose. Place hidden fields outside of rows instead.\n\n## Type Safety\n\nHidden fields are fully type-safe:\n\n```typescript\nimport { HiddenField } from '@ng-forge/dynamic-forms';\n\n// Typed hidden field\nconst idField: HiddenField<string> = {\n type: 'hidden',\n key: 'id',\n value: 'abc123',\n};\n\n// Array type\nconst tagsField: HiddenField<number[]> = {\n type: 'hidden',\n key: 'tagIds',\n value: [1, 2, 3],\n};\n```\n\nUse the `isHiddenField` type guard to check field types at runtime:\n\n```typescript\nimport { isHiddenField } from '@ng-forge/dynamic-forms';\n\nif (isHiddenField(field)) {\n console.log(field.value); // Type-safe access to value\n}\n```\n\n## Technical Details\n\n- **Componentless**: Hidden fields have no Angular component and render zero DOM elements\n- **Value handling**: Uses `valueHandling: 'include'`, so values are always included in form submissions\n- **Required value**: The `value` property is required (unlike most field types where it's optional)"
|
|
195
|
+
content: "Hidden fields store values in the form model without rendering any visible UI. They're useful for persisting IDs, metadata, or other non-user-facing data.\n\n## Basic Usage\n\n```typescript\n{\n type: 'hidden',\n key: 'id',\n value: 'uuid-550e8400-e29b-41d4-a716-446655440000',\n}\n```\n\nThis adds `id` to the form value without rendering anything:\n\n```typescript\n{\n id: 'uuid-550e8400-e29b-41d4-a716-446655440000',\n // ... other fields\n}\n```\n\n## Supported Value Types\n\nHidden fields support scalar values and arrays of scalars:\n\n```typescript\n// String\n{ type: 'hidden', key: 'sessionId', value: 'abc123' }\n\n// Number\n{ type: 'hidden', key: 'version', value: 42 }\n\n// Boolean\n{ type: 'hidden', key: 'isActive', value: true }\n\n// String array\n{ type: 'hidden', key: 'tags', value: ['draft', 'review'] }\n\n// Number array\n{ type: 'hidden', key: 'tagIds', value: [1, 2, 3] }\n\n// Boolean array\n{ type: 'hidden', key: 'flags', value: [true, false, true] }\n```\n\n## Complete Example\n\nHere's a form that uses hidden fields to persist metadata during an update operation:\n\n```typescript\nimport { Component } from '@angular/core';\nimport { DynamicForm } from '@ng-forge/dynamic-forms';\n\n@Component({\n selector: 'app-edit-user-form',\n imports: [DynamicForm],\n template: `<form [dynamic-form]=\"formConfig\"></form>`,\n})\nexport class EditUserFormComponent {\n formConfig = {\n fields: [\n // Hidden fields for metadata\n {\n type: 'hidden',\n key: 'id',\n value: 'user-123',\n },\n {\n type: 'hidden',\n key: 'version',\n value: 3,\n },\n {\n type: 'hidden',\n key: 'roles',\n value: ['admin', 'editor'],\n },\n // Visible fields\n {\n key: 'name',\n type: 'input',\n label: 'Full Name',\n value: 'John Doe',\n required: true,\n },\n {\n key: 'email',\n type: 'input',\n label: 'Email',\n value: 'john@example.com',\n required: true,\n props: { type: 'email' },\n },\n {\n key: 'submit',\n type: 'submit',\n label: 'Save Changes',\n },\n ],\n };\n}\n```\n\nForm value on submission:\n\n```typescript\n{\n id: 'user-123',\n version: 3,\n roles: ['admin', 'editor'],\n name: 'John Doe',\n email: 'john@example.com'\n}\n```\n\n## Use Cases\n\n- Persisting record IDs for update operations\n- Tracking version numbers for optimistic concurrency\n- Passing context data (parent IDs, source references)\n- Storing computed values that shouldn't be user-editable\n- Including metadata in form submissions\n\n## Placement\n\nHidden fields can be placed:\n\n- At the top level of a form\n- Inside page fields\n- Inside group fields\n\n**Note:** Placing hidden fields inside row fields will generate a validation warning. Rows are meant for horizontal layouts, and since hidden fields don't render anything, placing them in rows serves no purpose. Place hidden fields outside of rows instead.\n\n## Type Safety\n\nHidden fields are fully type-safe:\n\n```typescript\nimport { HiddenField } from '@ng-forge/dynamic-forms';\n\n// Typed hidden field\nconst idField: HiddenField<string> = {\n type: 'hidden',\n key: 'id',\n value: 'abc123',\n};\n\n// Array type\nconst tagsField: HiddenField<number[]> = {\n type: 'hidden',\n key: 'tagIds',\n value: [1, 2, 3],\n};\n```\n\nUse the `isHiddenField` type guard to check field types at runtime:\n\n```typescript\nimport { isHiddenField } from '@ng-forge/dynamic-forms';\n\nif (isHiddenField(field)) {\n console.log(field.value); // Type-safe access to value\n}\n```\n\n## Value Exclusion\n\nThe `HiddenField` type (`type: 'hidden'`) is **not affected** by value exclusion. Hidden fields:\n\n- Have no reactive `hidden()` state (they don't render a component)\n- Are always included in submission output, regardless of `excludeValueIfHidden` settings\n\nThis is different from regular fields with `hidden: true` or fields hidden via conditional logic. Those fields have a reactive `hidden()` state that value exclusion checks.\n\nSee the **Value Exclusion** page under Recipes for full details.\n\n## Technical Details\n\n- **Componentless**: Hidden fields have no Angular component and render zero DOM elements\n- **Value handling**: Uses `valueHandling: 'include'`, so values are always included in form submissions\n- **Required value**: The `value` property is required (unlike most field types where it's optional)"
|
|
160
196
|
},
|
|
161
197
|
{
|
|
162
198
|
id: "text-components",
|
|
@@ -228,13 +264,13 @@ var require_docs = __commonJS({
|
|
|
228
264
|
id: "validation-custom-validators",
|
|
229
265
|
title: "Validation Custom Validators",
|
|
230
266
|
category: "validation",
|
|
231
|
-
content: "Custom validation functions for complex validation logic that goes beyond built-in validators.\n\n## Overview\n\nng-forge supports three types of custom validators using Angular's Signal Forms API:\n\n1. **CustomValidator** - Synchronous validators with access to FieldContext\n2. **AsyncCustomValidator** - Async validators using Angular's resource API\n3. **HttpCustomValidator** - HTTP-specific validators with automatic request cancellation\n\n**Key Principle:** Validators should focus on validation logic, NOT presentation. Return only the error `kind` and configure messages at field level for proper i18n support.\n\n## Live Demo\n\nTry the interactive example below to see both expression-based and function-based validators in action:\n\n<iframe src=\"http://localhost:4201/#/examples/expression-validators\" class=\"example-frame\" title=\"Expression Validators Demo\"></iframe>\n\n## Message Resolution (STRICT)\n\nAll error messages MUST be explicitly configured. The framework enforces this strictly:\n\n1. **Field-level `validationMessages[kind]`** (highest priority - per-field customization)\n2. **Form-level `defaultValidationMessages[kind]`** (fallback for common messages)\n3. **No message configured = Console warning + error NOT displayed**\n\n**Important:** Validator-returned messages are NOT used as fallbacks. This enforces proper separation of concerns and i18n patterns.\n\nYou can define messages at the form level for common validation errors:\n\n```typescript\n{\n defaultValidationMessages: {\n noSpaces: 'Spaces are not allowed',\n passwordMismatch: 'Passwords must match'\n },\n fields: [/* ... */]\n}\n```\n\n## Two Validator Patterns\n\nng-forge supports two patterns for custom validators:\n\n1. **Function-based** - Register reusable validator functions (best for complex logic, reusability)\n2. **Expression-based** - Inline JavaScript expressions (best for simple, one-off validations)\n\n## Expression-Based Validators\n\nFor simple validation logic, use inline JavaScript expressions without registering functions.\n\n### Basic Example\n\n```typescript\n{\n key: 'confirmPassword',\n type: 'input',\n value: '',\n validators: [{\n type: 'custom',\n expression: 'fieldValue === formValue.password',\n kind: 'passwordMismatch',\n }],\n validationMessages: {\n passwordMismatch: 'Passwords must match',\n },\n}\n```\n\n**How it works:**\n\n- `fieldValue` - Current field's value\n- `formValue` - Entire form value object\n- Expression returns `true` = validation passes\n- Expression returns `false` = validation fails with the specified `kind`\n\n### Available Context\n\nExpression-based validators have access to:\n\n- **`fieldValue`** - Current field value\n- **`formValue`** - Complete form value object (e.g., `formValue.password`, `formValue.email`)\n- **`fieldPath`** - Current field path\n- Custom functions registered in `customFnConfig.customFunctions`\n\n### Safe Member Access\n\n**Built-in null/undefined handling**: Member access is safe by default - no manual null checks needed!\n\n```typescript\n// \u2705 Works safely even when nested values are null/undefined\n{\n expression: 'fieldValue !== formValue.user.profile.firstName',\n kind: 'invalidNested',\n}\n\n// \u274C Unnecessary - Don't do this\n{\n expression: '!formValue.user || !formValue.user.profile || !formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName',\n kind: 'invalidNested',\n}\n\n// \u2705 Better - Safe by default\n{\n expression: '!formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName',\n kind: 'invalidNested',\n}\n```\n\nAccessing properties on `null` or `undefined` returns `undefined` instead of throwing errors, making expressions cleaner and more maintainable.\n\n### Common Expression Patterns\n\n**Password confirmation:**\n\n```typescript\n{\n expression: 'fieldValue === formValue.password',\n kind: 'passwordMismatch',\n}\n```\n\n**Date range validation:**\n\n```typescript\n{\n expression: 'new Date(fieldValue) > new Date(formValue.startDate)',\n kind: 'endDateBeforeStart',\n}\n```\n\n**Conditional required:**\n\n```typescript\n{\n expression: 'formValue.requiresApproval ? fieldValue?.length > 0 : true',\n kind: 'approvalRequired',\n}\n```\n\n**Numeric comparison:**\n\n```typescript\n{\n expression: 'fieldValue >= formValue.minAge && fieldValue <= formValue.maxAge',\n kind: 'ageOutOfRange',\n}\n```\n\n**Deeply nested field validation:**\n\n```typescript\n{\n // Safe to access deeply nested properties\n expression: 'fieldValue.toLowerCase() !== formValue.user.address.city.toLowerCase()',\n kind: 'invalidAddress',\n}\n```\n\n### Security\n\nExpressions use **secure AST-based parsing** - no `eval()` or `new Function()`. Only safe JavaScript operations are allowed.\n\n## Function-Based Validators\n\nBest for validation that needs field value and access to other fields via FieldContext.\n\n### Basic Example\n\n```typescript\nimport { CustomValidator } from '@ng-forge/dynamic-forms';\n\n// \u2705 RECOMMENDED: Return only kind\nconst noSpaces: CustomValidator = (ctx) => {\n const value = ctx.value();\n if (typeof value === 'string' && value.includes(' ')) {\n return { kind: 'noSpaces' }; // No hardcoded message\n }\n return null;\n};\n\n// Register and configure message\nconst config = {\n fields: [\n {\n key: 'username',\n type: 'input',\n validators: [{ type: 'custom', functionName: 'noSpaces' }],\n validationMessages: {\n noSpaces: 'Spaces are not allowed', // Or Observable/Signal for i18n\n },\n },\n ],\n customFnConfig: {\n validators: {\n noSpaces,\n },\n },\n};\n```\n\n### FieldContext API\n\nThe `FieldContext` API provides access to:\n\n- **`ctx.value()`** - Current field value (signal)\n- **`ctx.state`** - Field state (errors, touched, dirty, etc.)\n- **`ctx.valueOf(path)`** - Access other field values (PUBLIC API for cross-field validation)\n- **`ctx.stateOf(path)`** - Access other field states\n- **`ctx.field`** - Current field tree\n\n### Cross-Field Validation\n\nUse `ctx.valueOf()` to access other field values for comparison validators:\n\n```typescript\nimport { CustomValidator } from '@ng-forge/dynamic-forms';\n\nconst greaterThanMin: CustomValidator = (ctx) => {\n const value = ctx.value();\n const minValue = ctx.valueOf('minAge');\n\n if (minValue !== undefined && value <= minValue) {\n return { kind: 'notGreaterThanMin' };\n }\n return null;\n};\n\n// Note: Custom validators return only 'kind'. Built-in validators (min, max, etc.)\n// automatically include params for interpolation (e.g., {{min}}, {{max}}, etc.)\nconst config = {\n fields: [\n { key: 'minAge', type: 'input', value: 0 },\n {\n key: 'maxAge',\n type: 'input',\n value: 0,\n validators: [\n {\n type: 'custom',\n functionName: 'greaterThanMin',\n },\n ],\n validationMessages: {\n notGreaterThanMin: 'Maximum age must be greater than minimum age',\n },\n },\n ],\n customFnConfig: {\n validators: { greaterThanMin },\n },\n};\n```\n\n**Common Cross-Field Patterns:**\n\n- Password confirmation matching\n- Date range validation (start < end)\n- Numeric range validation (min < max)\n- Conditional required fields\n\n### Password Confirmation Example\n\n```typescript\nconst passwordMatch: CustomValidator = (ctx) => {\n const confirmPassword = ctx.value();\n const password = ctx.valueOf('password');\n\n if (!confirmPassword || !password) {\n return null; // Let required validator handle empty case\n }\n\n if (password !== confirmPassword) {\n return { kind: 'passwordMismatch' };\n }\n return null;\n};\n\n// In field config:\n{\n key: 'confirmPassword',\n type: 'input',\n validators: [{ type: 'custom', functionName: 'passwordMatch' }],\n validationMessages: {\n passwordMismatch: 'Passwords do not match'\n }\n}\n```\n\n## Async Validators (Resource-based)\n\nAsync validators use Angular's resource API for database lookups or complex async operations.\n\n### Basic Example\n\n```typescript\nimport { AsyncCustomValidator } from '@ng-forge/dynamic-forms';\nimport { inject } from '@angular/core';\nimport { rxResource } from '@angular/core/rxjs-interop';\nimport { of } from 'rxjs';\nimport { UserService } from './user.service';\n\nconst checkUsernameAvailable: AsyncCustomValidator = {\n // Extract params from field context\n params: (ctx) => ({ username: ctx.value() }),\n\n // Create resource with params signal\n factory: (params) => {\n const userService = inject(UserService);\n return rxResource({\n request: params,\n loader: ({ request }) => {\n if (!request?.username) return of(null);\n return userService.checkAvailability(request.username);\n },\n });\n },\n\n // Map result to validation error\n onSuccess: (result, ctx) => {\n if (!result) return null;\n return result.available ? null : { kind: 'usernameTaken' };\n },\n\n // Handle errors gracefully\n onError: (error, ctx) => {\n console.error('Availability check failed:', error);\n return null; // Don't block form on network errors\n },\n};\n\nconst config = {\n fields: [\n {\n key: 'username',\n type: 'input',\n validators: [{ type: 'customAsync', functionName: 'checkUsernameAvailable' }],\n validationMessages: {\n usernameTaken: 'This username is already taken',\n },\n },\n ],\n customFnConfig: {\n asyncValidators: {\n checkUsernameAvailable,\n },\n },\n};\n```\n\n**Key Benefits:**\n\n- Automatic loading states via resource API\n- Angular manages resource lifecycle\n- Reactive - refetches when params change\n- Integrates with Signal Forms validation state\n\n### Structure\n\n```typescript\ninterface AsyncCustomValidator<TValue, TParams, TResult> {\n // Function that receives field context and returns resource params\n readonly params: (ctx: FieldContext<TValue>, config?: Record<string, unknown>) => TParams;\n\n // Function that creates a ResourceRef from the params signal\n readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;\n\n // Map successful resource result to validation errors\n readonly onSuccess?: (result: TResult, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;\n\n // Handle resource errors\n readonly onError?: (error: unknown, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;\n}\n```\n\n## HTTP Validators\n\nHTTP validators provide optimized HTTP validation with automatic request cancellation.\n\n### Basic Example\n\n```typescript\nimport { HttpCustomValidator } from '@ng-forge/dynamic-forms';\n\nconst checkEmailDomain: HttpCustomValidator = {\n // Build HTTP request from context\n request: (ctx) => {\n const email = ctx.value();\n if (!email?.includes('@')) return undefined; // Skip if invalid\n\n const domain = email.split('@')[1];\n return {\n url: `/api/validate-domain`,\n method: 'POST',\n body: { domain },\n headers: { 'Content-Type': 'application/json' },\n };\n },\n\n // NOTE: Inverted logic - onSuccess checks if response indicates INVALID\n // We're validating, not fetching data!\n onSuccess: (response, ctx) => {\n // Assuming API returns { valid: boolean }\n return response.valid ? null : { kind: 'invalidDomain' };\n },\n\n onError: (error, ctx) => {\n console.error('Domain validation failed:', error);\n return null; // Don't block form on network errors\n },\n};\n\nconst config = {\n fields: [\n {\n key: 'email',\n type: 'input',\n validators: [{ type: 'customHttp', functionName: 'checkEmailDomain' }],\n validationMessages: {\n invalidDomain: 'This email domain is not allowed',\n },\n },\n ],\n customFnConfig: {\n httpValidators: {\n checkEmailDomain,\n },\n },\n};\n```\n\n**Key Benefits:**\n\n- Automatic request cancellation when field changes\n- Built-in debouncing via resource API\n- Prevents race conditions\n- Optimized for HTTP-specific validation\n\n**Important:** HTTP validators use \"inverted logic\" - `onSuccess` should return an error if validation fails, not if the HTTP request succeeds. You're checking validation status, not fetching data.\n\n### Structure\n\n```typescript\ninterface HttpCustomValidator<TValue, TResult> {\n // Build HTTP request from field context\n readonly request: (ctx: FieldContext<TValue>) => HttpResourceRequest | string | undefined;\n\n // Map successful response to validation error\n readonly onSuccess?: (result: TResult, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;\n\n // Handle HTTP errors\n readonly onError?: (error: unknown, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;\n}\n\ninterface HttpResourceRequest {\n url: string;\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n body?: any;\n headers?: Record<string, string | string[]>;\n}\n```\n\n## Conditional Custom Validators\n\nApply validators conditionally using the `when` property with a `ConditionalExpression`:\n\n```typescript\nconst businessEmailValidator: CustomValidator = (ctx) => {\n const value = ctx.value();\n const domain = value?.split('@')[1];\n\n const freeEmailDomains = ['gmail.com', 'yahoo.com', 'hotmail.com'];\n\n if (domain && freeEmailDomains.includes(domain.toLowerCase())) {\n return { kind: 'requireBusinessEmail' };\n }\n return null;\n};\n\nconst config = {\n fields: [\n {\n key: 'accountType',\n type: 'select',\n value: 'personal',\n options: [\n { value: 'personal', label: 'Personal' },\n { value: 'business', label: 'Business' },\n ],\n },\n {\n key: 'email',\n type: 'input',\n validators: [\n {\n type: 'custom',\n functionName: 'businessEmailValidator',\n // Only apply when account type is \"business\"\n when: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n },\n ],\n validationMessages: {\n requireBusinessEmail: 'Please use a business email address',\n },\n },\n ],\n customFnConfig: {\n validators: { businessEmailValidator },\n },\n};\n```\n\nThe validator is only active when the `when` condition evaluates to `true`, allowing dynamic validation based on form state. See [Conditional Logic](../../dynamic-behavior/conditional-logic/overview/) for all expression types and operators.\n\n## Common Validation Patterns\n\n### Email Domain Validation\n\n```typescript\nconst emailDomainValidator: CustomValidator = (ctx) => {\n const blockedDomains = ['tempmail.com', 'throwaway.email'];\n const email = ctx.value();\n const domain = email?.split('@')[1];\n\n if (domain && blockedDomains.includes(domain)) {\n return { kind: 'blockedDomain' };\n }\n return null;\n};\n```\n\n### Age Validation\n\n```typescript\nconst ageValidator: CustomValidator = (ctx) => {\n const birthDate = ctx.value();\n const age = calculateAge(birthDate);\n\n if (age < 18) {\n return { kind: 'tooYoung' };\n }\n return null;\n};\n```\n\n### Conditional Required\n\n```typescript\nconst conditionalRequiredValidator: CustomValidator = (ctx) => {\n const value = ctx.value();\n const employmentStatus = ctx.valueOf('employmentStatus');\n\n // Company name required if employed\n if (employmentStatus === 'employed' && !value) {\n return { kind: 'required' };\n }\n return null;\n};\n```\n\n### Date Range Validation\n\n```typescript\nconst dateRangeValidator: CustomValidator = (ctx) => {\n const endDate = ctx.value();\n const startDate = ctx.valueOf('startDate');\n\n if (startDate && endDate && startDate > endDate) {\n return { kind: 'invalidDateRange' };\n }\n return null;\n};\n```\n\n## Multiple Errors\n\nValidators can return multiple errors for cross-field validation:\n\n```typescript\nconst validateDateRange: CustomValidator = (ctx) => {\n const errors: ValidationError[] = [];\n\n const startDate = ctx.valueOf('startDate');\n const endDate = ctx.valueOf('endDate');\n\n if (!startDate) errors.push({ kind: 'startDateRequired' });\n if (!endDate) errors.push({ kind: 'endDateRequired' });\n if (startDate && endDate && startDate > endDate) {\n errors.push({ kind: 'invalidDateRange' });\n }\n\n return errors.length > 0 ? errors : null;\n};\n```\n\n## Validation Messages\n\n### Field-Level Messages\n\n```typescript\n{\n key: 'username',\n validators: [{ type: 'custom', functionName: 'noSpaces' }],\n validationMessages: {\n noSpaces: 'Spaces are not allowed'\n }\n}\n```\n\n### Form-Level Default Messages\n\n```typescript\n{\n defaultValidationMessages: {\n noSpaces: 'Spaces are not allowed',\n passwordMismatch: 'Passwords must match',\n usernameTaken: 'This username is already taken'\n },\n fields: [/* ... */]\n}\n```\n\n### Dynamic Messages with i18n\n\n```typescript\nimport { inject } from '@angular/core';\nimport { TranslateService } from '@ngx-translate/core';\n\n{\n key: 'username',\n validators: [{ type: 'custom', functionName: 'noSpaces' }],\n validationMessages: {\n noSpaces: inject(TranslateService).get('VALIDATION.NO_SPACES')\n }\n}\n```\n\n### Parameterized Messages\n\nMessages can interpolate params from ValidatorConfig using Angular template syntax (double curly braces around the param name).\n\n**Syntax:** To interpolate a param, wrap its name in double curly braces (same syntax as Angular templates).\n\n**Example:** To access `params.label`, write the param name `label` wrapped in double curly braces in your message string.\n\n```typescript\n{\n validators: [\n {\n type: 'custom',\n functionName: 'lessThanField',\n params: { field: 'minAge', label: 'Minimum Age' }\n }\n ],\n validationMessages: {\n // Interpolate params.label using double curly braces\n notLessThan: 'Must be less than {{label}}'\n }\n}\n```\n\nThe validation message will render as **\"Must be less than Minimum Age\"** by interpolating the `label` param value.\n\n## Type Safety\n\nAll validator types are fully typed. While validators can optionally use generic type parameters for stricter typing, the simple form without generics works well for most cases:\n\n```typescript\n// Simple form - works for most cases\nconst noSpaces: CustomValidator = (ctx) => {\n const value = ctx.value();\n if (typeof value === 'string' && value.includes(' ')) {\n return { kind: 'noSpaces' };\n }\n return null;\n};\n\n// With type parameter - for stricter typing (advanced)\nconst strictNoSpaces: CustomValidator<string> = (ctx) => {\n const value = ctx.value(); // Type: string\n // TypeScript knows value is always string\n return value.includes(' ') ? { kind: 'noSpaces' } : null;\n};\n\n// Async validators with type parameters (advanced)\nconst checkUsername: AsyncCustomValidator<string, { username: string }, { available: boolean }> = {\n params: (ctx) => ({ username: ctx.value() }),\n factory: (params) => {\n /* ... */\n },\n onSuccess: (result, ctx) => {\n result.available; // Type: boolean\n return result.available ? null : { kind: 'usernameTaken' };\n },\n};\n\n// HTTP validators with type parameters (advanced)\nconst checkDomain: HttpCustomValidator<string, { valid: boolean }> = {\n request: (ctx) => ({\n /* ... */\n }),\n onSuccess: (response, ctx) => {\n response.valid; // Type: boolean\n return response.valid ? null : { kind: 'invalidDomain' };\n },\n};\n```\n\n**Note:** When registering validators in `customFnConfig.validators`, use the simple form without type parameters to avoid TypeScript compatibility issues.\n\n## Best Practices\n\n1. **Separation of Concerns**: Return only error `kind`, configure messages separately\n2. **i18n Support**: Use Observable/Signal for validation messages\n3. **Graceful Degradation**: Handle async/HTTP errors without blocking the form\n4. **Cross-Field Validation**: Use `ctx.valueOf()` for accessing related fields\n5. **Type Safety**: Leverage TypeScript generics for type-safe validation\n6. **Message Priority**: Use field-level messages for customization, form-level for common errors\n7. **Conditional Validation**: Use `when` property with `ConditionalExpression` for dynamic validators\n8. **Inverted Logic**: HTTP validators check validity, not data fetching success\n\n## Related Documentation\n\n- [Validation Basics](../basics/) - Core validation concepts\n- [Validation Reference](../reference/) - Standard validation rules\n- [Type Safety](../../advanced/type-safety/basics/) - TypeScript integration"
|
|
267
|
+
content: "Custom validation functions for complex validation logic that goes beyond built-in validators.\n\n## Overview\n\nng-forge supports four types of custom validators:\n\n1. **Declarative HTTP** (`type: 'http'`) - Validate against an HTTP endpoint with no function registration\n2. **CustomValidator** - Synchronous validators with access to FieldContext\n3. **AsyncCustomValidator** - Async validators using Angular's resource API\n4. **HttpCustomValidator** (function-based) - HTTP validators with full programmatic control\n\n**Key Principle:** Validators should focus on validation logic, NOT presentation. Return only the error `kind` and configure messages at field level for proper i18n support.\n\n## Live Demo\n\nTry the interactive example below to see both expression-based and function-based validators in action:\n\n<iframe src=\"http://localhost:4201/#/examples/expression-validators-demo\" class=\"example-frame\" title=\"Expression Validators Demo\"></iframe>\n\n## Message Resolution (STRICT)\n\nAll error messages MUST be explicitly configured. The framework enforces this strictly:\n\n1. **Field-level `validationMessages[kind]`** (highest priority - per-field customization)\n2. **Form-level `defaultValidationMessages[kind]`** (fallback for common messages)\n3. **No message configured = Console warning + error NOT displayed**\n\n**Important:** Validator-returned messages are NOT used as fallbacks. This enforces proper separation of concerns and i18n patterns.\n\nYou can define messages at the form level for common validation errors:\n\n```typescript\n{\n defaultValidationMessages: {\n noSpaces: 'Spaces are not allowed',\n passwordMismatch: 'Passwords must match'\n },\n fields: [/* ... */]\n}\n```\n\n## Two Validator Patterns\n\nng-forge supports two patterns for custom validators:\n\n1. **Function-based** - Register reusable validator functions (best for complex logic, reusability)\n2. **Expression-based** - Inline JavaScript expressions (best for simple, one-off validations)\n\n## Expression-Based Validators\n\nFor simple validation logic, use inline JavaScript expressions without registering functions.\n\n### Basic Example\n\n```typescript\n{\n key: 'confirmPassword',\n type: 'input',\n value: '',\n validators: [{\n type: 'custom',\n expression: 'fieldValue === formValue.password',\n kind: 'passwordMismatch',\n }],\n validationMessages: {\n passwordMismatch: 'Passwords must match',\n },\n}\n```\n\n**How it works:**\n\n- `fieldValue` - Current field's value\n- `formValue` - Entire form value object\n- Expression returns `true` = validation passes\n- Expression returns `false` = validation fails with the specified `kind`\n\n### Available Context\n\nExpression-based validators have access to:\n\n- **`fieldValue`** - Current field value\n- **`formValue`** - Complete form value object (e.g., `formValue.password`, `formValue.email`)\n- **`fieldPath`** - Current field path\n- Custom functions registered in `customFnConfig.customFunctions`\n\n### Safe Member Access\n\n**Built-in null/undefined handling**: Member access is safe by default - no manual null checks needed!\n\n```typescript\n// \u2705 Works safely even when nested values are null/undefined\n{\n expression: 'fieldValue !== formValue.user.profile.firstName',\n kind: 'invalidNested',\n}\n\n// \u274C Unnecessary - Don't do this\n{\n expression: '!formValue.user || !formValue.user.profile || !formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName',\n kind: 'invalidNested',\n}\n\n// \u2705 Better - Safe by default\n{\n expression: '!formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName',\n kind: 'invalidNested',\n}\n```\n\nAccessing properties on `null` or `undefined` returns `undefined` instead of throwing errors, making expressions cleaner and more maintainable.\n\n### Common Expression Patterns\n\n**Password confirmation:**\n\n```typescript\n{\n expression: 'fieldValue === formValue.password',\n kind: 'passwordMismatch',\n}\n```\n\n**Date range validation:**\n\n```typescript\n{\n expression: 'new Date(fieldValue) > new Date(formValue.startDate)',\n kind: 'endDateBeforeStart',\n}\n```\n\n**Conditional required:**\n\n```typescript\n{\n expression: 'formValue.requiresApproval ? fieldValue?.length > 0 : true',\n kind: 'approvalRequired',\n}\n```\n\n**Numeric comparison:**\n\n```typescript\n{\n expression: 'fieldValue >= formValue.minAge && fieldValue <= formValue.maxAge',\n kind: 'ageOutOfRange',\n}\n```\n\n**Deeply nested field validation:**\n\n```typescript\n{\n // Safe to access deeply nested properties\n expression: 'fieldValue.toLowerCase() !== formValue.user.address.city.toLowerCase()',\n kind: 'invalidAddress',\n}\n```\n\n### Security\n\nExpressions use **secure AST-based parsing**. Only safe JavaScript operations are allowed.\n\n## Function-Based Validators\n\nBest for validation that needs field value and access to other fields via FieldContext.\n\n### Basic Example\n\n```typescript\nimport { CustomValidator } from '@ng-forge/dynamic-forms';\n\n// \u2705 RECOMMENDED: Return only kind\nconst noSpaces: CustomValidator = (ctx) => {\n const value = ctx.value();\n if (typeof value === 'string' && value.includes(' ')) {\n return { kind: 'noSpaces' }; // No hardcoded message\n }\n return null;\n};\n\n// Register and configure message\nconst config = {\n fields: [\n {\n key: 'username',\n type: 'input',\n validators: [{ type: 'custom', functionName: 'noSpaces' }],\n validationMessages: {\n noSpaces: 'Spaces are not allowed', // Or Observable/Signal for i18n\n },\n },\n ],\n customFnConfig: {\n validators: {\n noSpaces,\n },\n },\n};\n```\n\n### FieldContext API\n\nThe `FieldContext` API provides access to:\n\n- **`ctx.value()`** - Current field value (signal)\n- **`ctx.state`** - Field state (errors, touched, dirty, etc.)\n- **`ctx.valueOf(path)`** - Access other field values (PUBLIC API for cross-field validation)\n- **`ctx.stateOf(path)`** - Access other field states\n- **`ctx.field`** - Current field tree\n\n### Cross-Field Validation\n\nUse `ctx.valueOf()` to access other field values for comparison validators:\n\n```typescript\nimport { CustomValidator } from '@ng-forge/dynamic-forms';\n\nconst greaterThanMin: CustomValidator = (ctx) => {\n const value = ctx.value();\n const minValue = ctx.valueOf('minAge');\n\n if (minValue !== undefined && value <= minValue) {\n return { kind: 'notGreaterThanMin' };\n }\n return null;\n};\n\n// Note: Custom validators return only 'kind'. Built-in validators (min, max, etc.)\n// automatically include params for interpolation (e.g., {{min}}, {{max}}, etc.)\nconst config = {\n fields: [\n { key: 'minAge', type: 'input', value: 0 },\n {\n key: 'maxAge',\n type: 'input',\n value: 0,\n validators: [\n {\n type: 'custom',\n functionName: 'greaterThanMin',\n },\n ],\n validationMessages: {\n notGreaterThanMin: 'Maximum age must be greater than minimum age',\n },\n },\n ],\n customFnConfig: {\n validators: { greaterThanMin },\n },\n};\n```\n\n**Common Cross-Field Patterns:**\n\n- Password confirmation matching\n- Date range validation (start < end)\n- Numeric range validation (min < max)\n- Conditional required fields\n\n### Password Confirmation Example\n\n```typescript\nconst passwordMatch: CustomValidator = (ctx) => {\n const confirmPassword = ctx.value();\n const password = ctx.valueOf('password');\n\n if (!confirmPassword || !password) {\n return null; // Let required validator handle empty case\n }\n\n if (password !== confirmPassword) {\n return { kind: 'passwordMismatch' };\n }\n return null;\n};\n\n// In field config:\n{\n key: 'confirmPassword',\n type: 'input',\n validators: [{ type: 'custom', functionName: 'passwordMatch' }],\n validationMessages: {\n passwordMismatch: 'Passwords do not match'\n }\n}\n```\n\n## Async Validators (Resource-based)\n\n> **Deprecation notice:** The old `type: 'customAsync'` still works but is deprecated. Use `type: 'async'` instead.\n\nAsync validators use Angular's resource API for database lookups or complex async operations.\n\n### Basic Example\n\n```typescript\nimport { AsyncCustomValidator } from '@ng-forge/dynamic-forms';\nimport { inject } from '@angular/core';\nimport { rxResource } from '@angular/core/rxjs-interop';\nimport { of } from 'rxjs';\nimport { UserService } from './user.service';\n\nconst checkUsernameAvailable: AsyncCustomValidator = {\n // Extract params from field context\n params: (ctx) => ({ username: ctx.value() }),\n\n // Create resource with params signal\n factory: (params) => {\n const userService = inject(UserService);\n return rxResource({\n request: params,\n loader: ({ request }) => {\n if (!request?.username) return of(null);\n return userService.checkAvailability(request.username);\n },\n });\n },\n\n // Map result to validation error\n onSuccess: (result, ctx) => {\n if (!result) return null;\n return result.available ? null : { kind: 'usernameTaken' };\n },\n\n // Handle errors gracefully\n onError: (error, ctx) => {\n console.error('Availability check failed:', error);\n return null; // Don't block form on network errors\n },\n};\n\nconst config = {\n fields: [\n {\n key: 'username',\n type: 'input',\n validators: [{ type: 'async', functionName: 'checkUsernameAvailable' }],\n validationMessages: {\n usernameTaken: 'This username is already taken',\n },\n },\n ],\n customFnConfig: {\n asyncValidators: {\n checkUsernameAvailable,\n },\n },\n};\n```\n\n**Key Benefits:**\n\n- Automatic loading states via resource API\n- Angular manages resource lifecycle\n- Reactive - refetches when params change\n- Integrates with Signal Forms validation state\n\n### Structure\n\n```typescript\ninterface AsyncCustomValidator<TValue, TParams, TResult> {\n // Function that receives field context and returns resource params\n readonly params: (ctx: FieldContext<TValue>, config?: Record<string, unknown>) => TParams;\n\n // Function that creates a ResourceRef from the params signal\n readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;\n\n // Map successful resource result to validation errors\n readonly onSuccess?: (result: TResult, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;\n\n // Handle resource errors\n readonly onError?: (error: unknown, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;\n}\n```\n\n## Declarative HTTP Validators\n\nThe simplest way to validate against an HTTP endpoint. No function registration required \u2014 configure the request and response mapping inline.\n\n### Basic Example\n\n```typescript\n{\n key: 'username',\n type: 'input',\n validators: [\n {\n type: 'http',\n http: {\n url: '/api/users/check-availability',\n method: 'GET',\n queryParams: {\n username: 'fieldValue', // fieldValue refers to the current field\n },\n },\n responseMapping: {\n validWhen: 'response.available', // Truthy = valid\n errorKind: 'usernameTaken',\n },\n },\n ],\n validationMessages: {\n usernameTaken: 'This username is already taken',\n },\n}\n```\n\n**Key points:**\n\n- `fieldValue` is available as an expression in `params`, `queryParams`, and `body` (refers to the current field's value)\n- Use `params` with `:key` URL placeholders for path parameters (e.g. `url: '/api/users/:id'` + `params: { id: 'fieldValue' }`)\n- `validWhen` is evaluated with `{ response }` in scope \u2014 truthy means validation passed\n- **Fail-closed:** if the HTTP request errors, the validator returns `{ kind: errorKind }` by default (prevents submission on network failure)\n\n### POST with Body Expressions\n\n```typescript\n{\n type: 'http',\n http: {\n url: '/api/validate-email',\n method: 'POST',\n body: {\n email: 'fieldValue',\n },\n evaluateBodyExpressions: true,\n },\n responseMapping: {\n validWhen: 'response.valid',\n errorKind: 'emailInvalid',\n },\n}\n```\n\n### Error Message Interpolation\n\nUse `errorParams` to include response data in validation messages:\n\n```typescript\n{\n type: 'http',\n http: {\n url: '/api/users/check-availability',\n queryParams: { username: 'fieldValue' },\n },\n responseMapping: {\n validWhen: 'response.available',\n errorKind: 'usernameTaken',\n errorParams: {\n suggestion: 'response.suggestion', // Maps to {{suggestion}} in the message\n },\n },\n}\n// Message: 'Username is taken. Try {{suggestion}}'\n```\n\n### HttpValidationResponseMapping Interface\n\n```typescript\ninterface HttpValidationResponseMapping {\n /** Expression evaluated with { response }. Truthy = valid (no error). */\n validWhen: string;\n\n /** Error kind returned when validWhen is falsy \u2014 maps to validationMessages. */\n errorKind: string;\n\n /**\n * Optional parameters for message interpolation.\n * Keys become {{paramName}} placeholders. Values are expressions evaluated with { response }.\n */\n errorParams?: Record<string, string>;\n}\n```\n\n**When to use declarative vs function-based:**\n\n| Scenario | Use |\n| ----------------------------------------------- | ------------------------------------------------ |\n| Standard availability check | Declarative (`type: 'http'` + `responseMapping`) |\n| Need `inject()` for Angular services | Function-based (`HttpCustomValidator`) |\n| Conditional request (skip based on field state) | Function-based (`HttpCustomValidator`) |\n| Custom error handling (fail-open on error) | Function-based (`HttpCustomValidator`) |\n\n## HTTP Validators (Function-Based)\n\n> **Deprecation notice:** The old `type: 'customHttp'` still works but is deprecated. Use `type: 'http'` with `functionName` instead.\n\nFunction-based HTTP validators give you full programmatic control over request construction and response handling.\n\n### Basic Example\n\n```typescript\nimport { HttpCustomValidator } from '@ng-forge/dynamic-forms';\n\nconst checkEmailDomain: HttpCustomValidator = {\n // Build HTTP request from context\n request: (ctx) => {\n const email = ctx.value();\n if (!email?.includes('@')) return undefined; // Skip if invalid\n\n const domain = email.split('@')[1];\n return {\n url: `/api/validate-domain`,\n method: 'POST',\n body: { domain },\n headers: { 'Content-Type': 'application/json' },\n };\n },\n\n // NOTE: Inverted logic - onSuccess checks if response indicates INVALID\n // We're validating, not fetching data!\n onSuccess: (response, ctx) => {\n // Assuming API returns { valid: boolean }\n return response.valid ? null : { kind: 'invalidDomain' };\n },\n\n onError: (error, ctx) => {\n console.error('Domain validation failed:', error);\n return null; // Don't block form on network errors\n },\n};\n\nconst config = {\n fields: [\n {\n key: 'email',\n type: 'input',\n validators: [{ type: 'http', functionName: 'checkEmailDomain' }],\n validationMessages: {\n invalidDomain: 'This email domain is not allowed',\n },\n },\n ],\n customFnConfig: {\n httpValidators: {\n checkEmailDomain,\n },\n },\n};\n```\n\n**Key Benefits:**\n\n- Automatic request cancellation when field changes\n- Built-in debouncing via resource API\n- Prevents race conditions\n- Optimized for HTTP-specific validation\n\n**Important:** HTTP validators use \"inverted logic\" - `onSuccess` should return an error if validation fails, not if the HTTP request succeeds. You're checking validation status, not fetching data.\n\n### Structure\n\n```typescript\ninterface HttpCustomValidator<TValue, TResult> {\n // Build HTTP request from field context\n readonly request: (ctx: FieldContext<TValue>) => HttpResourceRequest | string | undefined;\n\n // Map successful response to validation error\n readonly onSuccess?: (result: TResult, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;\n\n // Handle HTTP errors\n readonly onError?: (error: unknown, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;\n}\n\ninterface HttpResourceRequest {\n url: string;\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n body?: any;\n headers?: Record<string, string | string[]>;\n}\n```\n\n## Conditional Custom Validators\n\nApply validators conditionally using the `when` property with a `ConditionalExpression`:\n\n```typescript\nconst businessEmailValidator: CustomValidator = (ctx) => {\n const value = ctx.value();\n const domain = value?.split('@')[1];\n\n const freeEmailDomains = ['gmail.com', 'yahoo.com', 'hotmail.com'];\n\n if (domain && freeEmailDomains.includes(domain.toLowerCase())) {\n return { kind: 'requireBusinessEmail' };\n }\n return null;\n};\n\nconst config = {\n fields: [\n {\n key: 'accountType',\n type: 'select',\n value: 'personal',\n options: [\n { value: 'personal', label: 'Personal' },\n { value: 'business', label: 'Business' },\n ],\n },\n {\n key: 'email',\n type: 'input',\n validators: [\n {\n type: 'custom',\n functionName: 'businessEmailValidator',\n // Only apply when account type is \"business\"\n when: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n },\n ],\n validationMessages: {\n requireBusinessEmail: 'Please use a business email address',\n },\n },\n ],\n customFnConfig: {\n validators: { businessEmailValidator },\n },\n};\n```\n\nThe validator is only active when the `when` condition evaluates to `true`, allowing dynamic validation based on form state. See [Conditional Logic](../../dynamic-behavior/conditional-logic/overview/) for all expression types and operators.\n\n## Common Validation Patterns\n\n### Email Domain Validation\n\n```typescript\nconst emailDomainValidator: CustomValidator = (ctx) => {\n const blockedDomains = ['tempmail.com', 'throwaway.email'];\n const email = ctx.value();\n const domain = email?.split('@')[1];\n\n if (domain && blockedDomains.includes(domain)) {\n return { kind: 'blockedDomain' };\n }\n return null;\n};\n```\n\n### Age Validation\n\n```typescript\nconst ageValidator: CustomValidator = (ctx) => {\n const birthDate = ctx.value();\n const age = calculateAge(birthDate);\n\n if (age < 18) {\n return { kind: 'tooYoung' };\n }\n return null;\n};\n```\n\n### Conditional Required\n\n```typescript\nconst conditionalRequiredValidator: CustomValidator = (ctx) => {\n const value = ctx.value();\n const employmentStatus = ctx.valueOf('employmentStatus');\n\n // Company name required if employed\n if (employmentStatus === 'employed' && !value) {\n return { kind: 'required' };\n }\n return null;\n};\n```\n\n### Date Range Validation\n\n```typescript\nconst dateRangeValidator: CustomValidator = (ctx) => {\n const endDate = ctx.value();\n const startDate = ctx.valueOf('startDate');\n\n if (startDate && endDate && startDate > endDate) {\n return { kind: 'invalidDateRange' };\n }\n return null;\n};\n```\n\n## Multiple Errors\n\nValidators can return multiple errors for cross-field validation:\n\n```typescript\nconst validateDateRange: CustomValidator = (ctx) => {\n const errors: ValidationError[] = [];\n\n const startDate = ctx.valueOf('startDate');\n const endDate = ctx.valueOf('endDate');\n\n if (!startDate) errors.push({ kind: 'startDateRequired' });\n if (!endDate) errors.push({ kind: 'endDateRequired' });\n if (startDate && endDate && startDate > endDate) {\n errors.push({ kind: 'invalidDateRange' });\n }\n\n return errors.length > 0 ? errors : null;\n};\n```\n\n## Validation Messages\n\n### Field-Level Messages\n\n```typescript\n{\n key: 'username',\n validators: [{ type: 'custom', functionName: 'noSpaces' }],\n validationMessages: {\n noSpaces: 'Spaces are not allowed'\n }\n}\n```\n\n### Form-Level Default Messages\n\n```typescript\n{\n defaultValidationMessages: {\n noSpaces: 'Spaces are not allowed',\n passwordMismatch: 'Passwords must match',\n usernameTaken: 'This username is already taken'\n },\n fields: [/* ... */]\n}\n```\n\n### Dynamic Messages with i18n\n\n```typescript\nimport { inject } from '@angular/core';\nimport { TranslateService } from '@ngx-translate/core';\n\n{\n key: 'username',\n validators: [{ type: 'custom', functionName: 'noSpaces' }],\n validationMessages: {\n noSpaces: inject(TranslateService).get('VALIDATION.NO_SPACES')\n }\n}\n```\n\n### Parameterized Messages\n\nMessages can interpolate params from ValidatorConfig using Angular template syntax (double curly braces around the param name).\n\n**Syntax:** To interpolate a param, wrap its name in double curly braces (same syntax as Angular templates).\n\n**Example:** To access `params.label`, write the param name `label` wrapped in double curly braces in your message string.\n\n```typescript\n{\n validators: [\n {\n type: 'custom',\n functionName: 'lessThanField',\n params: { field: 'minAge', label: 'Minimum Age' }\n }\n ],\n validationMessages: {\n // Interpolate params.label using double curly braces\n notLessThan: 'Must be less than {{label}}'\n }\n}\n```\n\nThe validation message will render as **\"Must be less than Minimum Age\"** by interpolating the `label` param value.\n\n## Type Safety\n\nAll validator types are fully typed. While validators can optionally use generic type parameters for stricter typing, the simple form without generics works well for most cases:\n\n```typescript\n// Simple form - works for most cases\nconst noSpaces: CustomValidator = (ctx) => {\n const value = ctx.value();\n if (typeof value === 'string' && value.includes(' ')) {\n return { kind: 'noSpaces' };\n }\n return null;\n};\n\n// With type parameter - for stricter typing (advanced)\nconst strictNoSpaces: CustomValidator<string> = (ctx) => {\n const value = ctx.value(); // Type: string\n // TypeScript knows value is always string\n return value.includes(' ') ? { kind: 'noSpaces' } : null;\n};\n\n// Async validators with type parameters (advanced)\nconst checkUsername: AsyncCustomValidator<string, { username: string }, { available: boolean }> = {\n params: (ctx) => ({ username: ctx.value() }),\n factory: (params) => {\n /* ... */\n },\n onSuccess: (result, ctx) => {\n result.available; // Type: boolean\n return result.available ? null : { kind: 'usernameTaken' };\n },\n};\n\n// HTTP validators with type parameters (advanced)\nconst checkDomain: HttpCustomValidator<string, { valid: boolean }> = {\n request: (ctx) => ({\n /* ... */\n }),\n onSuccess: (response, ctx) => {\n response.valid; // Type: boolean\n return response.valid ? null : { kind: 'invalidDomain' };\n },\n};\n```\n\n**Note:** When registering validators in `customFnConfig.validators`, use the simple form without type parameters to avoid TypeScript compatibility issues.\n\n## Best Practices\n\n1. **Separation of Concerns**: Return only error `kind`, configure messages separately\n2. **i18n Support**: Use Observable/Signal for validation messages\n3. **Graceful Degradation**: Handle async/HTTP errors without blocking the form\n4. **Cross-Field Validation**: Use `ctx.valueOf()` for accessing related fields\n5. **Type Safety**: Leverage TypeScript generics for type-safe validation\n6. **Message Priority**: Use field-level messages for customization, form-level for common errors\n7. **Conditional Validation**: Use `when` property with `ConditionalExpression` for dynamic validators\n8. **Inverted Logic**: HTTP validators check validity, not data fetching success\n\n## Related Documentation\n\n- [Validation Basics](../basics/) - Core validation concepts\n- [Validation Reference](../reference/) - Standard validation rules\n- [Type Safety](../../advanced/type-safety/basics/) - TypeScript integration"
|
|
232
268
|
},
|
|
233
269
|
{
|
|
234
270
|
id: "validation-reference",
|
|
235
271
|
title: "Validation Reference",
|
|
236
272
|
category: "validation",
|
|
237
|
-
content: "Complete reference for all validators, conditional expressions, and validation configuration options.\n\n## All Validator Types\n\n### required\n\nField must have a value.\n\n```typescript\n// Shorthand\n{ required: true }\n\n// Explicit\n{\n validators: [{\n type: 'required',\n }],\n}\n```\n\n### email\n\nMust be a valid email format.\n\n```typescript\n// Shorthand\n{ email: true }\n\n// Explicit\n{\n validators: [{\n type: 'email',\n }],\n}\n```\n\n### minLength\n\nMinimum string length.\n\n```typescript\n// Shorthand\n{ minLength: 8 }\n\n// Explicit\n{\n validators: [{\n type: 'minLength',\n value: 8,\n }],\n}\n```\n\n### maxLength\n\nMaximum string length.\n\n```typescript\n// Shorthand\n{ maxLength: 20 }\n\n// Explicit\n{\n validators: [{\n type: 'maxLength',\n value: 20,\n }],\n}\n```\n\n### min\n\nMinimum numeric value.\n\n```typescript\n// Shorthand\n{ min: 0 }\n\n// Explicit\n{\n validators: [{\n type: 'min',\n value: 0,\n }],\n}\n```\n\n### max\n\nMaximum numeric value.\n\n```typescript\n// Shorthand\n{ max: 100 }\n\n// Explicit\n{\n validators: [{\n type: 'max',\n value: 100,\n }],\n}\n```\n\n### pattern\n\nRegular expression validation.\n\n```typescript\n// Shorthand\n{ pattern: '^[0-9]{5}$' }\n\n// Explicit\n{\n validators: [{\n type: 'pattern',\n value: '^[0-9]{5}$',\n }],\n}\n```\n\n## Conditional Expressions\n\nValidators support a `when` property for conditional validation. See **[Conditional Logic](../../dynamic-behavior/conditional-logic/overview/)** for the complete reference on:\n\n- All operators (`equals`, `notEquals`, `greater`, `less`, `contains`, `matches`, etc.)\n- Expression types (`fieldValue`, `formValue
|
|
273
|
+
content: "Complete reference for all validators, conditional expressions, and validation configuration options.\n\n## All Validator Types\n\n### required\n\nField must have a value.\n\n```typescript\n// Shorthand\n{ required: true }\n\n// Explicit\n{\n validators: [{\n type: 'required',\n }],\n}\n```\n\n### email\n\nMust be a valid email format.\n\n```typescript\n// Shorthand\n{ email: true }\n\n// Explicit\n{\n validators: [{\n type: 'email',\n }],\n}\n```\n\n### minLength\n\nMinimum string length.\n\n```typescript\n// Shorthand\n{ minLength: 8 }\n\n// Explicit\n{\n validators: [{\n type: 'minLength',\n value: 8,\n }],\n}\n```\n\n### maxLength\n\nMaximum string length.\n\n```typescript\n// Shorthand\n{ maxLength: 20 }\n\n// Explicit\n{\n validators: [{\n type: 'maxLength',\n value: 20,\n }],\n}\n```\n\n### min\n\nMinimum numeric value.\n\n```typescript\n// Shorthand\n{ min: 0 }\n\n// Explicit\n{\n validators: [{\n type: 'min',\n value: 0,\n }],\n}\n```\n\n### max\n\nMaximum numeric value.\n\n```typescript\n// Shorthand\n{ max: 100 }\n\n// Explicit\n{\n validators: [{\n type: 'max',\n value: 100,\n }],\n}\n```\n\n### pattern\n\nRegular expression validation.\n\n```typescript\n// Shorthand\n{ pattern: '^[0-9]{5}$' }\n\n// Explicit\n{\n validators: [{\n type: 'pattern',\n value: '^[0-9]{5}$',\n }],\n}\n```\n\n## Conditional Expressions\n\nValidators support a `when` property for conditional validation. See **[Conditional Logic](../../dynamic-behavior/conditional-logic/overview/)** for the complete reference on:\n\n- All operators (`equals`, `notEquals`, `greater`, `less`, `contains`, `matches`, etc.)\n- Expression types (`fieldValue`, `formValue` (deprecated), `javascript`, `custom`)\n- Combining conditions with `and`/`or` logic\n\n**Quick example:**\n\n```typescript\n{\n validators: [{\n type: 'required',\n when: {\n type: 'fieldValue',\n fieldPath: 'accountType',\n operator: 'equals',\n value: 'business',\n },\n }],\n}\n```\n\n## ValidatorConfig Types\n\nValidatorConfig is a discriminated union type with five variants:\n\n```typescript\n// Built-in validators (required, email, min, max, etc.)\ninterface BuiltInValidatorConfig {\n type: 'required' | 'email' | 'min' | 'max' | 'minLength' | 'maxLength' | 'pattern';\n value?: number | string | RegExp;\n expression?: string;\n when?: ConditionalExpression;\n}\n\n// Custom synchronous validators\ninterface CustomValidatorConfig {\n type: 'custom';\n functionName?: string;\n params?: Record<string, unknown>;\n expression?: string;\n kind?: string;\n errorParams?: Record<string, string>;\n when?: ConditionalExpression;\n}\n\n// Async validators (for debounced validation, database lookups)\ninterface AsyncValidatorConfig {\n // 'async' is preferred; 'customAsync' is deprecated and emits a warning in dev mode\n type: 'async' | 'customAsync';\n functionName: string;\n params?: Record<string, unknown>;\n when?: ConditionalExpression;\n}\n\n// Function-based HTTP validators (requires a registered function)\ninterface FunctionHttpValidatorConfig {\n // 'http' is preferred; 'customHttp' is deprecated and emits a warning in dev mode\n type: 'http' | 'customHttp';\n functionName: string;\n params?: Record<string, unknown>;\n when?: ConditionalExpression;\n}\n\n// Declarative HTTP validators (fully JSON-serializable, no function registration)\ninterface DeclarativeHttpValidatorConfig {\n type: 'http';\n http: HttpRequestConfig;\n responseMapping: HttpValidationResponseMapping;\n when?: ConditionalExpression;\n}\n\ntype ValidatorConfig =\n | BuiltInValidatorConfig\n | CustomValidatorConfig\n | AsyncValidatorConfig\n | FunctionHttpValidatorConfig\n | DeclarativeHttpValidatorConfig;\n```\n\n> **Deprecation notes:**\n>\n> - `type: 'customAsync'` is deprecated. Use `type: 'async'` instead.\n> - `type: 'customHttp'` is deprecated. Use `type: 'http'` instead.\n> - `FunctionHttpValidatorConfig` and `DeclarativeHttpValidatorConfig` both use `type: 'http'`. They are discriminated by property presence: `functionName` indicates function-based, `http` + `responseMapping` indicates declarative.\n\n### HttpRequestConfig\n\nUsed by `DeclarativeHttpValidatorConfig` to define the HTTP request:\n\n```typescript\ninterface HttpRequestConfig {\n url: string;\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; // defaults to 'GET'\n queryParams?: Record<string, string>; // values are expressions\n body?: Record<string, unknown>;\n evaluateBodyExpressions?: boolean; // when true, top-level string values in body are evaluated as expressions\n headers?: Record<string, string>;\n}\n```\n\n### HttpValidationResponseMapping\n\nUsed by `DeclarativeHttpValidatorConfig` to interpret the HTTP response:\n\n```typescript\ninterface HttpValidationResponseMapping {\n validWhen: string; // expression evaluated with { response } scope; truthy = valid\n errorKind: string; // error kind for validationMessages lookup\n errorParams?: Record<string, string>; // parameter expressions evaluated against { response }\n}\n```\n\n## ConditionalExpression Types\n\n`ConditionalExpression` is a discriminated union of six condition types. Each variant only allows the properties relevant to its type, providing compile-time safety against invalid property combinations.\n\n```typescript\n// Compare a specific field's value\ninterface FieldValueCondition {\n type: 'fieldValue';\n fieldPath: string;\n operator: ComparisonOperator;\n value?: unknown;\n}\n\n// Compare the entire form value (deprecated \u2014 use FieldValueCondition or JavascriptCondition instead)\ninterface FormValueCondition {\n type: 'formValue';\n operator: ComparisonOperator;\n value?: unknown;\n}\n\n// Invoke a registered custom function by name\ninterface CustomCondition {\n type: 'custom';\n expression: string; // registered function name\n}\n\n// Evaluate a JavaScript expression via the secure AST-based parser\ninterface JavascriptCondition {\n type: 'javascript';\n expression: string; // has access to formValue, fieldValue, externalData, etc.\n}\n\n// Logical AND \u2014 all sub-conditions must be true\ninterface AndCondition {\n type: 'and';\n conditions: ConditionalExpression[];\n}\n\n// Logical OR \u2014 at least one sub-condition must be true\ninterface OrCondition {\n type: 'or';\n conditions: ConditionalExpression[];\n}\n\ntype ComparisonOperator =\n | 'equals'\n | 'notEquals'\n | 'greater'\n | 'less'\n | 'greaterOrEqual'\n | 'lessOrEqual'\n | 'contains'\n | 'startsWith'\n | 'endsWith'\n | 'matches';\n\ntype ConditionalExpression = FieldValueCondition | FormValueCondition | CustomCondition | JavascriptCondition | AndCondition | OrCondition;\n```\n\n> **Deprecation note:** `FormValueCondition` (`type: 'formValue'`) is deprecated. Use `FieldValueCondition` for specific field checks, or `JavascriptCondition` with an expression for complex form-level comparisons.\n\n## Validation Messages\n\n### Per-Field Messages\n\n```typescript\n{\n key: 'email',\n type: 'input',\n value: '',\n required: true,\n email: true,\n validationMessages: {\n required: 'Email is required',\n email: 'Invalid email format',\n },\n}\n```\n\n### Dynamic Messages (i18n)\n\n```typescript\n{\n key: 'email',\n type: 'input',\n value: '',\n required: true,\n validationMessages: {\n required: this.transloco.selectTranslate('validation.required'),\n email: this.transloco.selectTranslate('validation.email'),\n },\n}\n```\n\n## Common Patterns\n\n### Email Validation\n\n```typescript\n{\n key: 'email',\n type: 'input',\n value: '',\n required: true,\n email: true,\n validationMessages: {\n required: 'Email is required',\n email: 'Please enter a valid email address',\n },\n}\n```\n\n### Password Requirements\n\n```typescript\n{\n key: 'password',\n type: 'input',\n value: '',\n required: true,\n minLength: 8,\n maxLength: 128,\n pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\\\d)(?=.*[@$!%*?&])',\n validationMessages: {\n required: 'Password is required',\n minLength: 'Password must be at least 8 characters',\n pattern: 'Password must include uppercase, lowercase, number, and special character',\n },\n props: { type: 'password' },\n}\n```\n\n### Numeric Range\n\n```typescript\n{\n key: 'age',\n type: 'input',\n value: null,\n required: true,\n min: 18,\n max: 120,\n validationMessages: {\n required: 'Age is required',\n min: 'Must be at least 18 years old',\n max: 'Please enter a valid age',\n },\n props: { type: 'number' },\n}\n```\n\n### ZIP Code\n\n```typescript\n{\n key: 'zipCode',\n type: 'input',\n value: '',\n required: true,\n pattern: '^[0-9]{5}(-[0-9]{4})?$',\n validationMessages: {\n required: 'ZIP code is required',\n pattern: 'Must be 5 digits or 5+4 format (e.g., 12345 or 12345-6789)',\n },\n}\n```\n\n### Phone Number\n\n```typescript\n{\n key: 'phone',\n type: 'input',\n value: '',\n pattern: '^\\\\+?[1-9]\\\\d{1,14}$',\n validationMessages: {\n pattern: 'Please enter a valid phone number (E.164 format preferred)',\n },\n props: { type: 'tel' },\n}\n```\n\n### URL\n\n```typescript\n{\n key: 'website',\n type: 'input',\n value: '',\n pattern: '^https?:\\\\/\\\\/.+',\n validationMessages: {\n pattern: 'Please enter a valid URL starting with http:// or https://',\n },\n props: { type: 'url' },\n}\n```\n\n## Related\n\n- **[Validation Basics](../basics/)** - Getting started with validation\n- **[Validation Advanced](../advanced/)** - Conditional validators\n- **[Conditional Logic](../../dynamic-behavior/conditional-logic/overview/)** - Field behavior changes\n- **[Type Safety](../../advanced/type-safety/basics/)** - TypeScript integration"
|
|
238
274
|
}
|
|
239
275
|
];
|
|
240
276
|
}
|
|
@@ -498,7 +534,7 @@ var field_types_default = [
|
|
|
498
534
|
{
|
|
499
535
|
type: "row",
|
|
500
536
|
category: "container",
|
|
501
|
-
description: "Horizontal layout container for grouping fields in columns. Rows do NOT have a label property
|
|
537
|
+
description: "Horizontal layout container for grouping fields in columns. Rows do NOT have a label property. Supports only 'hidden' logic type for conditional visibility.",
|
|
502
538
|
baseInterface: "FieldDef",
|
|
503
539
|
props: {
|
|
504
540
|
fields: {
|
|
@@ -531,13 +567,13 @@ var field_types_default = [
|
|
|
531
567
|
"previous"
|
|
532
568
|
],
|
|
533
569
|
cannotContain: ["page", "row", "hidden"],
|
|
534
|
-
example: "{\n key: 'nameRow',\n type: 'row',\n // NOTE: Rows do NOT have label
|
|
570
|
+
example: "{\n key: 'nameRow',\n type: 'row',\n // NOTE: Rows do NOT have a label property\n // Rows support only 'hidden' logic type for conditional visibility\n fields: [\n { key: 'firstName', type: 'input', label: 'First Name', col: 6 },\n { key: 'lastName', type: 'input', label: 'Last Name', col: 6 }\n ]\n}\n// NOT ALLOWED in rows:\n// - type: 'hidden' (hidden fields cannot be inside rows)\n// - type: 'page' (pages are top-level only)\n// - type: 'row' (no nested rows)",
|
|
535
571
|
minimalExample: "{ key: 'row1', type: 'row', fields: [...] }"
|
|
536
572
|
},
|
|
537
573
|
{
|
|
538
574
|
type: "group",
|
|
539
575
|
category: "container",
|
|
540
|
-
description: "Nested form group container creating a sub-object in form values. Groups are logical containers only and do NOT have a label property.",
|
|
576
|
+
description: "Nested form group container creating a sub-object in form values. Groups are logical containers only and do NOT have a label property. Supports only 'hidden' logic type for conditional visibility.",
|
|
541
577
|
valueType: "object",
|
|
542
578
|
baseInterface: "FieldDef",
|
|
543
579
|
props: {
|
|
@@ -577,7 +613,7 @@ var field_types_default = [
|
|
|
577
613
|
{
|
|
578
614
|
type: "array",
|
|
579
615
|
category: "container",
|
|
580
|
-
description: 'Repeatable field group for dynamic lists/arrays. Arrays do NOT have label
|
|
616
|
+
description: 'Repeatable field group for dynamic lists/arrays. Arrays do NOT have a label property. Use "fields" (not "template") to define the item template. Supports only \'hidden\' logic type for conditional visibility. Supports minLength/maxLength for array size validation. Each array item is rendered inside a `<div class="df-array-item">` wrapper with `role="group"`, `aria-label="Item N"` (1-based), `data-array-item-id`, and `data-array-item-index` attributes for styling, accessibility, and testing.',
|
|
581
617
|
valueType: "T[]",
|
|
582
618
|
baseInterface: "FieldDef",
|
|
583
619
|
props: {
|
|
@@ -586,9 +622,21 @@ var field_types_default = [
|
|
|
586
622
|
type: "ArrayAllowedChildren[]",
|
|
587
623
|
description: "Template field(s) for each array item. Allowed: rows, groups, leaf fields (NOT pages or other arrays)",
|
|
588
624
|
required: true
|
|
625
|
+
},
|
|
626
|
+
minLength: {
|
|
627
|
+
name: "minLength",
|
|
628
|
+
type: "number",
|
|
629
|
+
description: "Minimum number of items required in the array. Validation fails if fewer items.",
|
|
630
|
+
required: false
|
|
631
|
+
},
|
|
632
|
+
maxLength: {
|
|
633
|
+
name: "maxLength",
|
|
634
|
+
type: "number",
|
|
635
|
+
description: "Maximum number of items allowed in the array. Validation fails if more items.",
|
|
636
|
+
required: false
|
|
589
637
|
}
|
|
590
638
|
},
|
|
591
|
-
validationSupported:
|
|
639
|
+
validationSupported: true,
|
|
592
640
|
source: "core",
|
|
593
641
|
allowedIn: ["top-level", "page", "row", "group"],
|
|
594
642
|
notAllowedIn: ["array"],
|
|
@@ -612,7 +660,7 @@ var field_types_default = [
|
|
|
612
660
|
"previous"
|
|
613
661
|
],
|
|
614
662
|
cannotContain: ["page", "array"],
|
|
615
|
-
example: "// Flat array
|
|
663
|
+
example: "// Flat array with length validation\n{\n key: 'tags',\n type: 'array',\n minLength: 1, // At least one tag required\n maxLength: 5, // No more than 5 tags\n fields: [\n { key: 'tag', type: 'input', label: 'Tag' }\n ]\n}\n// Result: { tags: ['value1', 'value2'] }\n\n// Object array (nested groups)\n{\n key: 'contacts',\n type: 'array',\n minLength: 1,\n fields: [{\n type: 'group',\n fields: [\n { key: 'name', type: 'input', label: 'Name' },\n { key: 'email', type: 'input', label: 'Email' }\n ]\n }]\n}\n// Result: { contacts: [{name: '', email: ''}, ...] }",
|
|
616
664
|
minimalExample: "{ key: 'items', type: 'array', fields: [...] }"
|
|
617
665
|
},
|
|
618
666
|
{
|
|
@@ -855,10 +903,30 @@ var validators_default = [
|
|
|
855
903
|
},
|
|
856
904
|
example: "// Expression-based custom validator with error message\n// The validator:\n{\n type: 'custom',\n expression: 'fieldValue === formValue.password',\n kind: 'passwordMismatch' // Just the error KIND, not the message\n}\n\n// COMPLETE FIELD with validationMessages at FIELD level:\n{\n key: 'confirmPassword',\n type: 'input',\n label: 'Confirm Password',\n props: { type: 'password' },\n validators: [\n { type: 'custom', expression: 'fieldValue === formValue.password', kind: 'passwordMismatch' }\n ],\n validationMessages: {\n passwordMismatch: 'Passwords do not match' // Message goes HERE at field level\n }\n}"
|
|
857
905
|
},
|
|
906
|
+
{
|
|
907
|
+
type: "async",
|
|
908
|
+
category: "async",
|
|
909
|
+
description: "Async validator using registered async function (resource-based). Register the function via customFnConfig.asyncValidators.",
|
|
910
|
+
parameters: {
|
|
911
|
+
functionName: {
|
|
912
|
+
name: "functionName",
|
|
913
|
+
type: "string",
|
|
914
|
+
description: "Name of registered async validator function",
|
|
915
|
+
required: true
|
|
916
|
+
},
|
|
917
|
+
params: {
|
|
918
|
+
name: "params",
|
|
919
|
+
type: "Record<string, unknown>",
|
|
920
|
+
description: "Parameters to pass to validator function",
|
|
921
|
+
required: false
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
example: "{\n type: 'async',\n functionName: 'checkUsernameAvailability'\n}"
|
|
925
|
+
},
|
|
858
926
|
{
|
|
859
927
|
type: "customAsync",
|
|
860
928
|
category: "async",
|
|
861
|
-
description: "Async validator using registered async function",
|
|
929
|
+
description: "DEPRECATED \u2014 use type: 'async' instead. Async validator using registered async function.",
|
|
862
930
|
parameters: {
|
|
863
931
|
functionName: {
|
|
864
932
|
name: "functionName",
|
|
@@ -873,12 +941,12 @@ var validators_default = [
|
|
|
873
941
|
required: false
|
|
874
942
|
}
|
|
875
943
|
},
|
|
876
|
-
example: "{\n type: 'customAsync',\n functionName: 'checkUsernameAvailability'\n}"
|
|
944
|
+
example: "// DEPRECATED \u2014 use type: 'async' instead\n{\n type: 'customAsync',\n functionName: 'checkUsernameAvailability'\n}"
|
|
877
945
|
},
|
|
878
946
|
{
|
|
879
947
|
type: "customHttp",
|
|
880
948
|
category: "http",
|
|
881
|
-
description: "HTTP-based validator with automatic request cancellation",
|
|
949
|
+
description: "DEPRECATED \u2014 use type: 'http' with functionName instead. HTTP-based validator with automatic request cancellation.",
|
|
882
950
|
parameters: {
|
|
883
951
|
functionName: {
|
|
884
952
|
name: "functionName",
|
|
@@ -893,7 +961,39 @@ var validators_default = [
|
|
|
893
961
|
required: false
|
|
894
962
|
}
|
|
895
963
|
},
|
|
896
|
-
example: "{\n type: 'customHttp',\n functionName: 'validateEmailExists'\n}"
|
|
964
|
+
example: "// DEPRECATED \u2014 use type: 'http' with functionName instead\n{\n type: 'customHttp',\n functionName: 'validateEmailExists'\n}"
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
type: "http",
|
|
968
|
+
category: "http",
|
|
969
|
+
description: "HTTP validator \u2014 supports two modes: (1) Declarative (fully JSON-serializable, no function registration) using http + responseMapping properties, or (2) Function-based using functionName to reference a registered HTTP validator function. Query param values and (optionally) body values are expressions evaluated against the form context. Response mapping uses validWhen (truthy = valid) and errorKind (maps to field-level validationMessages).",
|
|
970
|
+
parameters: {
|
|
971
|
+
functionName: {
|
|
972
|
+
name: "functionName",
|
|
973
|
+
type: "string",
|
|
974
|
+
description: "Name of registered HTTP validator function (function-based mode). Mutually exclusive with http + responseMapping.",
|
|
975
|
+
required: false
|
|
976
|
+
},
|
|
977
|
+
http: {
|
|
978
|
+
name: "http",
|
|
979
|
+
type: "HttpRequestConfig",
|
|
980
|
+
description: "HTTP request configuration: { url, method?, queryParams?, body?, evaluateBodyExpressions?, headers?, debounceMs? }. queryParams values are expressions. When evaluateBodyExpressions is true, top-level string values in body are evaluated as expressions (shallow only). Required for declarative mode.",
|
|
981
|
+
required: false
|
|
982
|
+
},
|
|
983
|
+
responseMapping: {
|
|
984
|
+
name: "responseMapping",
|
|
985
|
+
type: "HttpValidationResponseMapping",
|
|
986
|
+
description: "Response mapping: { validWhen, errorKind, errorParams? }. validWhen is an expression evaluated with { response } scope \u2014 truthy means valid. errorKind maps to field-level validationMessages. Required for declarative mode.",
|
|
987
|
+
required: false
|
|
988
|
+
},
|
|
989
|
+
params: {
|
|
990
|
+
name: "params",
|
|
991
|
+
type: "Record<string, unknown>",
|
|
992
|
+
description: "Parameters to pass to HTTP validator function (function-based mode only).",
|
|
993
|
+
required: false
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
example: "// HTTP validator (fully JSON-serializable)\n{\n type: 'http',\n http: {\n url: '/api/validate-username',\n method: 'GET',\n queryParams: {\n username: 'fieldValue'\n }\n },\n responseMapping: {\n validWhen: 'response.available',\n errorKind: 'usernameTaken',\n errorParams: {\n suggestion: 'response.suggestion'\n }\n }\n}\n\n// COMPLETE FIELD with validationMessages at FIELD level:\n{\n key: 'username',\n type: 'input',\n label: 'Username',\n validators: [\n {\n type: 'http',\n http: {\n url: '/api/validate-username',\n method: 'GET',\n queryParams: { username: 'fieldValue' }\n },\n responseMapping: {\n validWhen: 'response.available',\n errorKind: 'usernameTaken',\n errorParams: { suggestion: 'response.suggestion' }\n }\n }\n ],\n validationMessages: {\n usernameTaken: 'Username is taken. Try {{suggestion}}'\n }\n}"
|
|
897
997
|
}
|
|
898
998
|
];
|
|
899
999
|
|
|
@@ -1697,13 +1797,13 @@ Use when you need nested object structure in form values:
|
|
|
1697
1797
|
|
|
1698
1798
|
#### Array (Repeatable Items)
|
|
1699
1799
|
|
|
1700
|
-
Use for dynamic lists
|
|
1800
|
+
Use for dynamic lists. Each item is wrapped in a \`<div class="df-array-item">\` with \`role="group"\` and \`aria-label="Item N"\` for accessibility. Use \`--df-array-item-gap\` CSS variable to control spacing between items.
|
|
1701
1801
|
|
|
1702
1802
|
\`\`\`typescript
|
|
1703
1803
|
{
|
|
1704
1804
|
key: 'contacts',
|
|
1705
1805
|
type: 'array',
|
|
1706
|
-
label
|
|
1806
|
+
// \u26A0\uFE0F NO label property on arrays!
|
|
1707
1807
|
fields: [
|
|
1708
1808
|
{
|
|
1709
1809
|
key: 'contact',
|
|
@@ -3083,7 +3183,22 @@ var BaseFieldDefSchema = z14.object({
|
|
|
3083
3183
|
/**
|
|
3084
3184
|
* Grid column width (1-12) in a 12-column layout system.
|
|
3085
3185
|
*/
|
|
3086
|
-
col: z14.number().min(1).max(12).optional()
|
|
3186
|
+
col: z14.number().min(1).max(12).optional(),
|
|
3187
|
+
/**
|
|
3188
|
+
* Whether to exclude this field's value from submission output when hidden.
|
|
3189
|
+
* Overrides both the global and form-level settings.
|
|
3190
|
+
*/
|
|
3191
|
+
excludeValueIfHidden: z14.boolean().optional(),
|
|
3192
|
+
/**
|
|
3193
|
+
* Whether to exclude this field's value from submission output when disabled.
|
|
3194
|
+
* Overrides both the global and form-level settings.
|
|
3195
|
+
*/
|
|
3196
|
+
excludeValueIfDisabled: z14.boolean().optional(),
|
|
3197
|
+
/**
|
|
3198
|
+
* Whether to exclude this field's value from submission output when readonly.
|
|
3199
|
+
* Overrides both the global and form-level settings.
|
|
3200
|
+
*/
|
|
3201
|
+
excludeValueIfReadonly: z14.boolean().optional()
|
|
3087
3202
|
});
|
|
3088
3203
|
|
|
3089
3204
|
// internal/dynamic-forms-zod/src/lib/schemas/field/field-with-validation.schema.ts
|
|
@@ -3826,7 +3941,10 @@ var HiddenFieldSchema = BaseFieldDefSchema.omit({
|
|
|
3826
3941
|
readonly: true,
|
|
3827
3942
|
hidden: true,
|
|
3828
3943
|
tabIndex: true,
|
|
3829
|
-
col: true
|
|
3944
|
+
col: true,
|
|
3945
|
+
excludeValueIfHidden: true,
|
|
3946
|
+
excludeValueIfDisabled: true,
|
|
3947
|
+
excludeValueIfReadonly: true
|
|
3830
3948
|
}).extend({
|
|
3831
3949
|
/**
|
|
3832
3950
|
* Field type discriminant.
|
|
@@ -3864,7 +3982,7 @@ var MatLeafFieldSchema = z35.discriminatedUnion("type", [
|
|
|
3864
3982
|
|
|
3865
3983
|
// internal/dynamic-forms-zod/src/lib/schemas/containers/factories.ts
|
|
3866
3984
|
import { z as z36 } from "zod";
|
|
3867
|
-
var
|
|
3985
|
+
var ContainerLogicSchema = z36.object({
|
|
3868
3986
|
type: z36.literal("hidden"),
|
|
3869
3987
|
condition: z36.union([ConditionalExpressionSchema, z36.boolean()])
|
|
3870
3988
|
});
|
|
@@ -3891,31 +4009,61 @@ function createContainerSchemas(options) {
|
|
|
3891
4009
|
const PageFieldSchema = ContainerBaseSchema.extend({
|
|
3892
4010
|
type: z36.literal("page"),
|
|
3893
4011
|
fields: z36.array(AnyFieldSchema),
|
|
3894
|
-
logic: z36.array(
|
|
4012
|
+
logic: z36.array(ContainerLogicSchema).optional(),
|
|
3895
4013
|
// Also forbid title (common mistake)
|
|
3896
4014
|
title: z36.never().optional()
|
|
3897
4015
|
});
|
|
3898
4016
|
const RowFieldSchema = ContainerBaseSchema.extend({
|
|
3899
4017
|
type: z36.literal("row"),
|
|
3900
4018
|
fields: z36.array(AnyFieldSchema),
|
|
3901
|
-
//
|
|
3902
|
-
logic: z36.
|
|
4019
|
+
// Container logic - only 'hidden' type allowed (same as pages)
|
|
4020
|
+
logic: z36.array(ContainerLogicSchema).optional()
|
|
3903
4021
|
});
|
|
3904
4022
|
const GroupFieldSchema = ContainerBaseSchema.extend({
|
|
3905
4023
|
type: z36.literal("group"),
|
|
3906
4024
|
fields: z36.array(AnyFieldSchema),
|
|
3907
|
-
//
|
|
3908
|
-
logic: z36.
|
|
4025
|
+
// Container logic - only 'hidden' type allowed (same as pages)
|
|
4026
|
+
logic: z36.array(ContainerLogicSchema).optional()
|
|
3909
4027
|
});
|
|
3910
|
-
const
|
|
4028
|
+
const ArrayButtonConfigSchema = z36.object({
|
|
4029
|
+
label: z36.string().optional(),
|
|
4030
|
+
props: z36.record(z36.unknown()).optional()
|
|
4031
|
+
});
|
|
4032
|
+
const FullArrayFieldSchema = ContainerBaseSchema.extend({
|
|
3911
4033
|
type: z36.literal("array"),
|
|
3912
4034
|
fields: z36.array(AnyFieldSchema),
|
|
3913
|
-
|
|
3914
|
-
|
|
4035
|
+
logic: z36.array(ContainerLogicSchema).optional(),
|
|
4036
|
+
// Full API does not use template
|
|
3915
4037
|
template: z36.never().optional(),
|
|
4038
|
+
// Array length validation
|
|
4039
|
+
minLength: z36.number().int().min(0).optional(),
|
|
4040
|
+
maxLength: z36.number().int().min(0).optional()
|
|
4041
|
+
});
|
|
4042
|
+
const ArrayAllowedChildSchema = z36.lazy(
|
|
4043
|
+
() => AnyFieldSchema.refine((field) => field.type !== "page" && field.type !== "array", {
|
|
4044
|
+
message: "Array templates cannot contain page or array fields. Only leaf fields, rows, and groups are allowed."
|
|
4045
|
+
})
|
|
4046
|
+
);
|
|
4047
|
+
const SimplifiedArrayFieldSchema = ContainerBaseSchema.extend({
|
|
4048
|
+
type: z36.literal("array"),
|
|
4049
|
+
// Template: single field (primitive array) or array of fields (object array)
|
|
4050
|
+
// Only ArrayAllowedChildren (leaf fields, rows, groups) are valid — no pages or nested arrays.
|
|
4051
|
+
template: z36.union([ArrayAllowedChildSchema, z36.array(ArrayAllowedChildSchema)]),
|
|
4052
|
+
// Initial values for the array
|
|
4053
|
+
value: z36.array(z36.unknown()).optional(),
|
|
4054
|
+
// Button customization or opt-out (false to disable)
|
|
4055
|
+
addButton: z36.union([ArrayButtonConfigSchema, z36.literal(false)]).optional(),
|
|
4056
|
+
removeButton: z36.union([ArrayButtonConfigSchema, z36.literal(false)]).optional(),
|
|
4057
|
+
logic: z36.array(ContainerLogicSchema).optional(),
|
|
4058
|
+
// Array length validation
|
|
4059
|
+
minLength: z36.number().int().min(0).optional(),
|
|
4060
|
+
maxLength: z36.number().int().min(0).optional(),
|
|
4061
|
+
// Simplified API does not use fields
|
|
4062
|
+
fields: z36.never().optional(),
|
|
3916
4063
|
minItems: z36.never().optional(),
|
|
3917
4064
|
maxItems: z36.never().optional()
|
|
3918
4065
|
});
|
|
4066
|
+
const ArrayFieldSchema = z36.union([FullArrayFieldSchema, SimplifiedArrayFieldSchema]);
|
|
3919
4067
|
const AllFieldsSchema = AnyFieldSchema;
|
|
3920
4068
|
return {
|
|
3921
4069
|
PageFieldSchema,
|
|
@@ -3986,7 +4134,22 @@ var FormOptionsSchema = z37.object({
|
|
|
3986
4134
|
/**
|
|
3987
4135
|
* Options for the next button behavior (multi-page forms).
|
|
3988
4136
|
*/
|
|
3989
|
-
nextButton: NextButtonOptionsSchema.optional()
|
|
4137
|
+
nextButton: NextButtonOptionsSchema.optional(),
|
|
4138
|
+
/**
|
|
4139
|
+
* Whether to exclude values of hidden fields from submission output.
|
|
4140
|
+
* Overrides the global withValueExclusionDefaults() setting for this form.
|
|
4141
|
+
*/
|
|
4142
|
+
excludeValueIfHidden: z37.boolean().optional(),
|
|
4143
|
+
/**
|
|
4144
|
+
* Whether to exclude values of disabled fields from submission output.
|
|
4145
|
+
* Overrides the global withValueExclusionDefaults() setting for this form.
|
|
4146
|
+
*/
|
|
4147
|
+
excludeValueIfDisabled: z37.boolean().optional(),
|
|
4148
|
+
/**
|
|
4149
|
+
* Whether to exclude values of readonly fields from submission output.
|
|
4150
|
+
* Overrides the global withValueExclusionDefaults() setting for this form.
|
|
4151
|
+
*/
|
|
4152
|
+
excludeValueIfReadonly: z37.boolean().optional()
|
|
3990
4153
|
});
|
|
3991
4154
|
|
|
3992
4155
|
// internal/dynamic-forms-zod/src/lib/schemas/form/schema-definition.schema.ts
|
|
@@ -4883,13 +5046,11 @@ var PROPERTY_GUIDANCE = {
|
|
|
4883
5046
|
maxValue: "maxValue should be at FIELD level, not inside props",
|
|
4884
5047
|
step: "step should be at FIELD level, not inside props (for slider)",
|
|
4885
5048
|
fields: "fields should be at FIELD level, contains child fields for containers",
|
|
4886
|
-
template:
|
|
5049
|
+
template: "Arrays support two APIs: (1) Full API with `fields` for explicit item definitions, or (2) Simplified API with `template` + `value` for common cases. `fields` and `template` are mutually exclusive.",
|
|
4887
5050
|
label: "Container types (row, group, array, page) do NOT have a label property",
|
|
4888
5051
|
title: "Page fields do NOT have a title property",
|
|
4889
|
-
minItems: "Array fields do NOT have minItems/maxItems properties",
|
|
4890
|
-
maxItems: "Array fields do NOT have minItems/maxItems properties",
|
|
4891
5052
|
expressions: 'Use "derivation" or "logic" instead of "expressions"',
|
|
4892
|
-
logic:
|
|
5053
|
+
logic: 'Container fields (page, group, row, array) only support the "hidden" logic type. For other logic types (disabled, required, readonly, derivation), apply them to child fields instead'
|
|
4893
5054
|
};
|
|
4894
5055
|
var DID_YOU_MEAN = {
|
|
4895
5056
|
hideWhen: 'Did you mean `logic: [{ type: "hidden", condition: {...} }]`? There is no hideWhen shorthand.',
|
|
@@ -4908,7 +5069,7 @@ var DID_YOU_MEAN = {
|
|
|
4908
5069
|
min: "Did you mean `minValue` at field level (for slider) or `min` shorthand validator (for numbers)?",
|
|
4909
5070
|
max: "Did you mean `maxValue` at field level (for slider) or `max` shorthand validator (for numbers)?",
|
|
4910
5071
|
items: "Did you mean `options` for select/radio/multi-checkbox, or `fields` for array containers?",
|
|
4911
|
-
template: "
|
|
5072
|
+
template: "Arrays support `template` (simplified API) or `fields` (full API). If using `template`, also provide `value` for initial data. They are mutually exclusive - do not use both.",
|
|
4912
5073
|
element: 'Did you mean `props: { elementType: "..." }`? For text fields, use elementType inside props.',
|
|
4913
5074
|
content: "Did you mean `label`? For text fields, the content goes in the label property.",
|
|
4914
5075
|
title: 'Did you mean to use a `text` field with `label: "..." and props: { elementType: "h1" }`? Pages don\'t have titles.',
|
|
@@ -4957,11 +5118,24 @@ function formatZodError(error, config) {
|
|
|
4957
5118
|
mistakes.push("Use minValue/maxValue/step at field level, not min/max/step in props");
|
|
4958
5119
|
}
|
|
4959
5120
|
}
|
|
4960
|
-
if (["
|
|
5121
|
+
if (["group", "row", "page"].includes(fieldType) && "template" in field) {
|
|
4961
5122
|
mistakes.push('Use "fields" instead of "template"');
|
|
4962
5123
|
}
|
|
5124
|
+
if (fieldType === "array" && "template" in field && "fields" in field) {
|
|
5125
|
+
mistakes.push('"fields" and "template" are mutually exclusive on arrays. Use one or the other.');
|
|
5126
|
+
}
|
|
4963
5127
|
if (["row", "group", "array"].includes(fieldType) && "logic" in field) {
|
|
4964
|
-
|
|
5128
|
+
const logicArr = field["logic"];
|
|
5129
|
+
if (Array.isArray(logicArr)) {
|
|
5130
|
+
const nonHidden = logicArr.filter(
|
|
5131
|
+
(l) => l && typeof l === "object" && l["type"] !== "hidden"
|
|
5132
|
+
);
|
|
5133
|
+
if (nonHidden.length > 0) {
|
|
5134
|
+
mistakes.push(
|
|
5135
|
+
`"${fieldType}" containers only support 'hidden' logic type - for other logic types, apply to child fields instead`
|
|
5136
|
+
);
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
4965
5139
|
}
|
|
4966
5140
|
if (fieldType === "hidden") {
|
|
4967
5141
|
if (!("value" in field)) {
|
|
@@ -4975,6 +5149,7 @@ function formatZodError(error, config) {
|
|
|
4975
5149
|
}
|
|
4976
5150
|
for (const [wrongProp, suggestion] of Object.entries(DID_YOU_MEAN)) {
|
|
4977
5151
|
if (wrongProp in field) {
|
|
5152
|
+
if (wrongProp === "template" && fieldType === "array") continue;
|
|
4978
5153
|
mistakes.push(suggestion);
|
|
4979
5154
|
}
|
|
4980
5155
|
}
|
|
@@ -5153,7 +5328,7 @@ var EXPECTED_STRUCTURE = {
|
|
|
5153
5328
|
slider: `{ key: 'fieldKey', type: 'slider', label: 'Label', minValue: 0, maxValue: 100, step: 1 }`,
|
|
5154
5329
|
row: `{ key: 'rowKey', type: 'row', fields: [...childFields] }`,
|
|
5155
5330
|
group: `{ key: 'groupKey', type: 'group', fields: [...childFields] }`,
|
|
5156
|
-
array: `{ key: 'arrayKey', type: 'array', fields: [...
|
|
5331
|
+
array: `Full API: { key: 'arrayKey', type: 'array', fields: [...itemDefs] } OR Simplified API: { key: 'arrayKey', type: 'array', template: { type: 'input', label: 'Item' }, value: [] }`,
|
|
5157
5332
|
page: `{ key: 'pageKey', type: 'page', fields: [...childFields, { key: 'next', type: 'next', label: 'Next' }] }`
|
|
5158
5333
|
};
|
|
5159
5334
|
function preValidateConfig(config) {
|
|
@@ -5210,6 +5385,9 @@ function preValidateConfig(config) {
|
|
|
5210
5385
|
}
|
|
5211
5386
|
for (const prop of CONTAINER_FORBIDDEN_PROPS) {
|
|
5212
5387
|
if (prop in f) {
|
|
5388
|
+
if (prop === "value" && fieldType === "array" && "template" in f) {
|
|
5389
|
+
continue;
|
|
5390
|
+
}
|
|
5213
5391
|
const expected = EXPECTED_STRUCTURE[fieldType || ""] || "";
|
|
5214
5392
|
errors.push({
|
|
5215
5393
|
path: `${path}.${prop}`,
|
|
@@ -5217,11 +5395,17 @@ function preValidateConfig(config) {
|
|
|
5217
5395
|
});
|
|
5218
5396
|
}
|
|
5219
5397
|
}
|
|
5220
|
-
if ("logic" in f) {
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5398
|
+
if ("logic" in f && Array.isArray(f["logic"])) {
|
|
5399
|
+
const nonHiddenLogic = f["logic"].filter(
|
|
5400
|
+
(l) => l && typeof l === "object" && l["type"] !== "hidden"
|
|
5401
|
+
);
|
|
5402
|
+
if (nonHiddenLogic.length > 0) {
|
|
5403
|
+
const invalidTypes = nonHiddenLogic.map((l) => `"${l["type"]}"`).join(", ");
|
|
5404
|
+
errors.push({
|
|
5405
|
+
path: `${path}.logic`,
|
|
5406
|
+
message: `"${fieldType}" containers only support 'hidden' logic type. Found unsupported logic types: ${invalidTypes}. For other logic types (disabled, required, readonly, derivation), apply them to child fields instead.`
|
|
5407
|
+
});
|
|
5408
|
+
}
|
|
5225
5409
|
}
|
|
5226
5410
|
}
|
|
5227
5411
|
if (fieldType === "page") {
|
|
@@ -5321,7 +5505,7 @@ function preValidateConfig(config) {
|
|
|
5321
5505
|
});
|
|
5322
5506
|
}
|
|
5323
5507
|
}
|
|
5324
|
-
if (["row", "group", "
|
|
5508
|
+
if (["row", "group", "page"].includes(fieldType || "")) {
|
|
5325
5509
|
if (!("fields" in f)) {
|
|
5326
5510
|
errors.push({
|
|
5327
5511
|
path: `${path}.fields`,
|
|
@@ -5334,12 +5518,42 @@ function preValidateConfig(config) {
|
|
|
5334
5518
|
});
|
|
5335
5519
|
}
|
|
5336
5520
|
}
|
|
5521
|
+
if (fieldType === "array") {
|
|
5522
|
+
const hasFields = "fields" in f;
|
|
5523
|
+
const hasTemplate = "template" in f;
|
|
5524
|
+
if (hasFields && hasTemplate) {
|
|
5525
|
+
errors.push({
|
|
5526
|
+
path,
|
|
5527
|
+
message: `Array "${fieldKey || "unknown"}" has BOTH "fields" and "template". These are mutually exclusive. Use "fields" for the full API or "template" + "value" for the simplified API. ${EXPECTED_STRUCTURE["array"]}`
|
|
5528
|
+
});
|
|
5529
|
+
} else if (!hasFields && !hasTemplate) {
|
|
5530
|
+
errors.push({
|
|
5531
|
+
path,
|
|
5532
|
+
message: `Array "${fieldKey || "unknown"}" is MISSING both "fields" and "template". Use "fields" (full API) or "template" + "value" (simplified API). ${EXPECTED_STRUCTURE["array"]}`
|
|
5533
|
+
});
|
|
5534
|
+
} else if (hasFields && !Array.isArray(f["fields"])) {
|
|
5535
|
+
errors.push({
|
|
5536
|
+
path: `${path}.fields`,
|
|
5537
|
+
message: `Array "${fieldKey || "unknown"}" has invalid "fields" - must be an array of field objects, not ${typeof f["fields"]}.`
|
|
5538
|
+
});
|
|
5539
|
+
}
|
|
5540
|
+
}
|
|
5337
5541
|
const childFields = f["fields"];
|
|
5338
5542
|
if (Array.isArray(childFields)) {
|
|
5339
5543
|
childFields.forEach((child, idx) => {
|
|
5340
5544
|
checkField(child, `${path}.fields[${idx}]`, fieldType);
|
|
5341
5545
|
});
|
|
5342
5546
|
}
|
|
5547
|
+
if (fieldType === "array" && "template" in f) {
|
|
5548
|
+
const template = f["template"];
|
|
5549
|
+
if (Array.isArray(template)) {
|
|
5550
|
+
template.forEach((child, idx) => {
|
|
5551
|
+
checkField(child, `${path}.template[${idx}]`, fieldType);
|
|
5552
|
+
});
|
|
5553
|
+
} else if (template && typeof template === "object") {
|
|
5554
|
+
checkField(template, `${path}.template`, fieldType);
|
|
5555
|
+
}
|
|
5556
|
+
}
|
|
5343
5557
|
}
|
|
5344
5558
|
fields.forEach((field, idx) => {
|
|
5345
5559
|
checkField(field, `fields[${idx}]`, void 0);
|
|
@@ -5714,14 +5928,15 @@ Included in form value but not rendered.
|
|
|
5714
5928
|
brief: `\`\`\`typescript
|
|
5715
5929
|
{ key: 'address', type: 'group', fields: [{ key: 'city', type: 'input', label: 'City' }] }
|
|
5716
5930
|
\`\`\`
|
|
5717
|
-
\u26A0\uFE0F **NO label
|
|
5931
|
+
\u26A0\uFE0F **NO label** - supports only \`hidden\` logic - creates nested object { address: { city: '...' } }`,
|
|
5718
5932
|
full: `# Group Container (Nested Object)
|
|
5719
5933
|
|
|
5720
5934
|
\`\`\`typescript
|
|
5721
5935
|
{
|
|
5722
5936
|
key: 'address',
|
|
5723
5937
|
type: 'group',
|
|
5724
|
-
// \u26A0\uFE0F NO LABEL!
|
|
5938
|
+
// \u26A0\uFE0F NO LABEL! Supports only 'hidden' logic type.
|
|
5939
|
+
logic: [{ type: 'hidden', condition: { type: 'fieldValue', fieldPath: 'showAddress', operator: 'equals', value: false } }],
|
|
5725
5940
|
fields: [
|
|
5726
5941
|
{ key: 'street', type: 'input', label: 'Street' },
|
|
5727
5942
|
{ key: 'city', type: 'input', label: 'City' }
|
|
@@ -5732,7 +5947,7 @@ Included in form value but not rendered.
|
|
|
5732
5947
|
|
|
5733
5948
|
**\u26A0\uFE0F Common mistakes:**
|
|
5734
5949
|
- Adding \`label\` to group (not allowed)
|
|
5735
|
-
-
|
|
5950
|
+
- Using non-hidden logic types on group (only \`hidden\` is supported, apply other logic types to child fields instead)
|
|
5736
5951
|
|
|
5737
5952
|
**Can contain:** rows, leaf fields (NOT pages, NOT nested groups)`
|
|
5738
5953
|
},
|
|
@@ -5740,14 +5955,15 @@ Included in form value but not rendered.
|
|
|
5740
5955
|
brief: `\`\`\`typescript
|
|
5741
5956
|
{ key: 'nameRow', type: 'row', fields: [{ key: 'first', type: 'input', label: 'First', col: 6 }, { key: 'last', type: 'input', label: 'Last', col: 6 }] }
|
|
5742
5957
|
\`\`\`
|
|
5743
|
-
\u26A0\uFE0F **NO label, NO
|
|
5958
|
+
\u26A0\uFE0F **NO label, NO hidden fields inside** - supports only \`hidden\` logic type`,
|
|
5744
5959
|
full: `# Row Container (Horizontal Layout)
|
|
5745
5960
|
|
|
5746
5961
|
\`\`\`typescript
|
|
5747
5962
|
{
|
|
5748
5963
|
key: 'nameRow',
|
|
5749
5964
|
type: 'row',
|
|
5750
|
-
// \u26A0\uFE0F NO LABEL!
|
|
5965
|
+
// \u26A0\uFE0F NO LABEL! Supports only 'hidden' logic type.
|
|
5966
|
+
logic: [{ type: 'hidden', condition: { type: 'fieldValue', fieldPath: 'showNameRow', operator: 'equals', value: false } }],
|
|
5751
5967
|
fields: [
|
|
5752
5968
|
{ key: 'firstName', type: 'input', label: 'First', col: 6 },
|
|
5753
5969
|
{ key: 'lastName', type: 'input', label: 'Last', col: 6 }
|
|
@@ -5755,7 +5971,7 @@ Included in form value but not rendered.
|
|
|
5755
5971
|
}
|
|
5756
5972
|
\`\`\`
|
|
5757
5973
|
|
|
5758
|
-
Use \`col\` (1-12) on child fields for grid width. Row is purely for layout.
|
|
5974
|
+
Use \`col\` (1-12) on child fields for grid width. Row is purely for layout. Supports only \`hidden\` logic type for conditional visibility.
|
|
5759
5975
|
|
|
5760
5976
|
**Allowed children:** groups, arrays, value fields (input, select, checkbox, etc.)
|
|
5761
5977
|
**NOT allowed:** \`hidden\` fields, \`page\` containers, nested \`row\` containers
|
|
@@ -5763,36 +5979,250 @@ Use \`col\` (1-12) on child fields for grid width. Row is purely for layout.
|
|
|
5763
5979
|
\u26A0\uFE0F **Common mistake:** Putting hidden fields inside rows - hidden fields should be at page or form level, not in rows.`
|
|
5764
5980
|
},
|
|
5765
5981
|
array: {
|
|
5766
|
-
brief:
|
|
5767
|
-
{ key: '
|
|
5768
|
-
|
|
5769
|
-
\u26A0\uFE0F
|
|
5982
|
+
brief: `**Two APIs available:**
|
|
5983
|
+
**Simplified (recommended):** \`{ key: 'tags', type: 'array', template: { key: 'value', type: 'input', label: 'Tag' }, value: ['a', 'b'] }\`
|
|
5984
|
+
**Full:** \`{ key: 'contacts', type: 'array', fields: [[{ key: 'name', type: 'input', value: 'Alice' }]] }\`
|
|
5985
|
+
\u26A0\uFE0F **NO label** - supports only \`hidden\` logic type. See topic \`simplified-array\` for the simplified API.`,
|
|
5770
5986
|
full: `# Array Container (Dynamic List)
|
|
5771
5987
|
|
|
5988
|
+
Arrays support two APIs: **Simplified** (recommended for common cases) and **Full** (for advanced use cases).
|
|
5989
|
+
|
|
5990
|
+
## Simplified API (Recommended)
|
|
5991
|
+
|
|
5992
|
+
Use \`template\` + \`value\` for common array patterns. Add/remove buttons are auto-generated.
|
|
5993
|
+
|
|
5994
|
+
\`\`\`typescript
|
|
5995
|
+
// Primitive array: ['angular', 'typescript']
|
|
5996
|
+
{
|
|
5997
|
+
key: 'tags',
|
|
5998
|
+
type: 'array',
|
|
5999
|
+
template: { key: 'value', type: 'input', label: 'Tag' },
|
|
6000
|
+
value: ['angular', 'typescript']
|
|
6001
|
+
}
|
|
6002
|
+
|
|
6003
|
+
// Object array: [{ name: 'Jane', phone: '555' }]
|
|
6004
|
+
{
|
|
6005
|
+
key: 'contacts',
|
|
6006
|
+
type: 'array',
|
|
6007
|
+
template: [
|
|
6008
|
+
{ key: 'name', type: 'input', label: 'Name', required: true },
|
|
6009
|
+
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
6010
|
+
],
|
|
6011
|
+
value: [{ name: 'Jane', phone: '555' }]
|
|
6012
|
+
}
|
|
6013
|
+
|
|
6014
|
+
// Custom button config
|
|
6015
|
+
{
|
|
6016
|
+
key: 'items',
|
|
6017
|
+
type: 'array',
|
|
6018
|
+
template: { key: 'value', type: 'input', label: 'Item' },
|
|
6019
|
+
addButton: { label: 'Add Item', props: { color: 'primary' } },
|
|
6020
|
+
removeButton: false // Disable remove buttons
|
|
6021
|
+
}
|
|
6022
|
+
\`\`\`
|
|
6023
|
+
|
|
6024
|
+
See topic \`simplified-array\` for full documentation on the simplified API.
|
|
6025
|
+
|
|
6026
|
+
## Full API (Advanced)
|
|
6027
|
+
|
|
6028
|
+
Use \`fields\` for explicit item definitions with full control.
|
|
6029
|
+
|
|
5772
6030
|
\`\`\`typescript
|
|
5773
6031
|
{
|
|
5774
6032
|
key: 'contacts',
|
|
5775
6033
|
type: 'array',
|
|
5776
|
-
// \u26A0\uFE0F NO LABEL!
|
|
6034
|
+
// \u26A0\uFE0F NO LABEL! Supports only 'hidden' logic type.
|
|
6035
|
+
logic: [{ type: 'hidden', condition: { type: 'fieldValue', fieldPath: 'hasContacts', operator: 'equals', value: false } }],
|
|
5777
6036
|
fields: [
|
|
5778
|
-
|
|
5779
|
-
key: '
|
|
5780
|
-
type: '
|
|
5781
|
-
|
|
5782
|
-
{ key: 'name', type: 'input', label: 'Name' },
|
|
5783
|
-
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
5784
|
-
]
|
|
5785
|
-
}
|
|
6037
|
+
[
|
|
6038
|
+
{ key: 'name', type: 'input', label: 'Name', value: 'Alice' },
|
|
6039
|
+
{ key: 'phone', type: 'input', label: 'Phone', value: '555' }
|
|
6040
|
+
]
|
|
5786
6041
|
]
|
|
5787
6042
|
}
|
|
5788
|
-
// Output: { contacts: [{ name: '
|
|
6043
|
+
// Output: { contacts: [{ name: 'Alice', phone: '555' }] }
|
|
5789
6044
|
\`\`\`
|
|
5790
6045
|
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
-
|
|
6046
|
+
**DOM Structure:**
|
|
6047
|
+
Each array item is wrapped in a \`<div class="df-array-item">\` with:
|
|
6048
|
+
- \`role="group"\` + \`aria-label="Item N"\` (1-based) for accessibility
|
|
6049
|
+
- \`data-array-item-id\` (stable ID) and \`data-array-item-index\` (current position) for testing/CSS
|
|
6050
|
+
- Use \`--df-array-item-gap\` CSS variable to control spacing between items
|
|
6051
|
+
|
|
6052
|
+
**Key rules:**
|
|
6053
|
+
- \`fields\` and \`template\` are **mutually exclusive** - use one or the other
|
|
6054
|
+
- NO \`label\` property on arrays
|
|
6055
|
+
- Only \`hidden\` logic type is supported
|
|
6056
|
+
- Arrays cannot contain pages or other arrays
|
|
5794
6057
|
|
|
5795
6058
|
**Can contain:** rows, groups, leaf fields (NOT pages, NOT nested arrays)`
|
|
6059
|
+
},
|
|
6060
|
+
"simplified-array": {
|
|
6061
|
+
brief: `\`\`\`typescript
|
|
6062
|
+
// Primitive: { key: 'tags', type: 'array', template: { key: 'value', type: 'input', label: 'Tag' }, value: ['a', 'b'] }
|
|
6063
|
+
// Object: { key: 'contacts', type: 'array', template: [{ key: 'name', type: 'input' }, { key: 'phone', type: 'input' }], value: [{ name: 'Jane', phone: '555' }] }
|
|
6064
|
+
// Buttons: { addButton: { label: 'Add Tag', props: { color: 'primary' } }, removeButton: false }
|
|
6065
|
+
\`\`\`
|
|
6066
|
+
Discriminant: \`template\` presence = simplified API; \`Array.isArray(template)\` = object vs primitive.`,
|
|
6067
|
+
full: `# Simplified Array API
|
|
6068
|
+
|
|
6069
|
+
The simplified array API provides a concise way to define dynamic arrays using \`template\` + \`value\` instead of verbose \`fields\` definitions. Add/remove buttons are auto-generated.
|
|
6070
|
+
|
|
6071
|
+
## When to Use
|
|
6072
|
+
|
|
6073
|
+
Use the simplified API when:
|
|
6074
|
+
- You have a common primitive or object array pattern
|
|
6075
|
+
- You want auto-generated add/remove buttons
|
|
6076
|
+
- Initial values are known and simple
|
|
6077
|
+
|
|
6078
|
+
Use the full API (\`fields\`) when:
|
|
6079
|
+
- You need heterogeneous items (different field configs per item)
|
|
6080
|
+
- You need custom button placement
|
|
6081
|
+
- You need explicit control over each item's structure
|
|
6082
|
+
|
|
6083
|
+
## Primitive Array
|
|
6084
|
+
|
|
6085
|
+
Each item is a single value (string, number, etc.):
|
|
6086
|
+
|
|
6087
|
+
\`\`\`typescript
|
|
6088
|
+
{
|
|
6089
|
+
key: 'tags',
|
|
6090
|
+
type: 'array',
|
|
6091
|
+
template: { key: 'value', type: 'input', label: 'Tag', required: true },
|
|
6092
|
+
value: ['angular', 'typescript']
|
|
6093
|
+
}
|
|
6094
|
+
// Output: { tags: ['angular', 'typescript'] }
|
|
6095
|
+
\`\`\`
|
|
6096
|
+
|
|
6097
|
+
**Discriminant:** \`template\` is a single field definition (not an array).
|
|
6098
|
+
|
|
6099
|
+
## Object Array
|
|
6100
|
+
|
|
6101
|
+
Each item is an object with multiple fields:
|
|
6102
|
+
|
|
6103
|
+
\`\`\`typescript
|
|
6104
|
+
{
|
|
6105
|
+
key: 'contacts',
|
|
6106
|
+
type: 'array',
|
|
6107
|
+
template: [
|
|
6108
|
+
{ key: 'name', type: 'input', label: 'Contact Name', required: true },
|
|
6109
|
+
{ key: 'phone', type: 'input', label: 'Phone Number' }
|
|
6110
|
+
],
|
|
6111
|
+
value: [
|
|
6112
|
+
{ name: 'Jane Doe', phone: '555-1234' },
|
|
6113
|
+
{ name: 'John Smith', phone: '555-5678' }
|
|
6114
|
+
]
|
|
6115
|
+
}
|
|
6116
|
+
// Output: { contacts: [{ name: 'Jane Doe', phone: '555-1234' }, ...] }
|
|
6117
|
+
\`\`\`
|
|
6118
|
+
|
|
6119
|
+
**Discriminant:** \`template\` is an array of field definitions.
|
|
6120
|
+
|
|
6121
|
+
## Button Configuration
|
|
6122
|
+
|
|
6123
|
+
### Custom Add Button
|
|
6124
|
+
\`\`\`typescript
|
|
6125
|
+
{
|
|
6126
|
+
key: 'tags',
|
|
6127
|
+
type: 'array',
|
|
6128
|
+
template: { key: 'value', type: 'input', label: 'Tag' },
|
|
6129
|
+
addButton: {
|
|
6130
|
+
label: 'Add New Tag',
|
|
6131
|
+
props: { color: 'primary' }
|
|
6132
|
+
}
|
|
6133
|
+
}
|
|
6134
|
+
\`\`\`
|
|
6135
|
+
|
|
6136
|
+
### Custom Remove Button
|
|
6137
|
+
\`\`\`typescript
|
|
6138
|
+
{
|
|
6139
|
+
key: 'items',
|
|
6140
|
+
type: 'array',
|
|
6141
|
+
template: { key: 'value', type: 'input', label: 'Item' },
|
|
6142
|
+
removeButton: {
|
|
6143
|
+
label: 'Delete',
|
|
6144
|
+
props: { color: 'warn' }
|
|
6145
|
+
}
|
|
6146
|
+
}
|
|
6147
|
+
\`\`\`
|
|
6148
|
+
|
|
6149
|
+
### Disable Buttons
|
|
6150
|
+
\`\`\`typescript
|
|
6151
|
+
{
|
|
6152
|
+
key: 'tags',
|
|
6153
|
+
type: 'array',
|
|
6154
|
+
template: { key: 'value', type: 'input', label: 'Tag' },
|
|
6155
|
+
addButton: false, // No add button
|
|
6156
|
+
removeButton: false // No remove button per item
|
|
6157
|
+
}
|
|
6158
|
+
\`\`\`
|
|
6159
|
+
|
|
6160
|
+
## ArrayButtonConfig Interface
|
|
6161
|
+
|
|
6162
|
+
\`\`\`typescript
|
|
6163
|
+
interface ArrayButtonConfig {
|
|
6164
|
+
label?: string; // Custom button label
|
|
6165
|
+
props?: Record<string, unknown>; // Additional button component properties
|
|
6166
|
+
}
|
|
6167
|
+
\`\`\`
|
|
6168
|
+
|
|
6169
|
+
## SimplifiedArrayField Interface
|
|
6170
|
+
|
|
6171
|
+
\`\`\`typescript
|
|
6172
|
+
interface SimplifiedArrayField {
|
|
6173
|
+
type: 'array';
|
|
6174
|
+
key: string;
|
|
6175
|
+
template: ArrayAllowedChildren | readonly ArrayAllowedChildren[]; // Item structure
|
|
6176
|
+
value?: readonly unknown[]; // Initial values
|
|
6177
|
+
addButton?: ArrayButtonConfig | false; // Add button config (default: { label: 'Add' })
|
|
6178
|
+
removeButton?: ArrayButtonConfig | false; // Remove button config (default: { label: 'Remove' })
|
|
6179
|
+
logic?: ContainerLogicConfig[]; // Only 'hidden' type supported
|
|
6180
|
+
// label is NOT supported
|
|
6181
|
+
// fields is NOT supported (mutually exclusive with template)
|
|
6182
|
+
}
|
|
6183
|
+
\`\`\`
|
|
6184
|
+
|
|
6185
|
+
## How It Works Internally
|
|
6186
|
+
|
|
6187
|
+
The simplified API is normalized into the full API at runtime:
|
|
6188
|
+
1. Each \`value\` element creates an item using the \`template\` structure
|
|
6189
|
+
2. An \`addArrayItem\` button is auto-generated after the array (unless \`addButton: false\`)
|
|
6190
|
+
3. A \`removeArrayItem\` button is added to each item (unless \`removeButton: false\`)
|
|
6191
|
+
|
|
6192
|
+
## Conditional Visibility
|
|
6193
|
+
|
|
6194
|
+
\`\`\`typescript
|
|
6195
|
+
{
|
|
6196
|
+
key: 'tags',
|
|
6197
|
+
type: 'array',
|
|
6198
|
+
template: { key: 'value', type: 'input', label: 'Tag' },
|
|
6199
|
+
logic: [{
|
|
6200
|
+
type: 'hidden',
|
|
6201
|
+
condition: { type: 'fieldValue', fieldPath: 'hasTags', operator: 'equals', value: false }
|
|
6202
|
+
}]
|
|
6203
|
+
}
|
|
6204
|
+
\`\`\`
|
|
6205
|
+
|
|
6206
|
+
## Common Mistakes
|
|
6207
|
+
|
|
6208
|
+
\u274C Mixing \`fields\` and \`template\`:
|
|
6209
|
+
\`\`\`typescript
|
|
6210
|
+
// WRONG - mutually exclusive
|
|
6211
|
+
{ type: 'array', fields: [...], template: {...} }
|
|
6212
|
+
|
|
6213
|
+
// CORRECT - use one or the other
|
|
6214
|
+
{ type: 'array', template: { key: 'value', type: 'input', label: 'Tag' }, value: ['a'] }
|
|
6215
|
+
{ type: 'array', fields: [{ key: 'tag', type: 'input', value: 'a' }] }
|
|
6216
|
+
\`\`\`
|
|
6217
|
+
|
|
6218
|
+
\u274C Wrong value shape for object templates:
|
|
6219
|
+
\`\`\`typescript
|
|
6220
|
+
// WRONG - value should match template keys
|
|
6221
|
+
{ template: [{ key: 'name', type: 'input' }], value: ['Jane'] }
|
|
6222
|
+
|
|
6223
|
+
// CORRECT - value elements are objects matching template keys
|
|
6224
|
+
{ template: [{ key: 'name', type: 'input' }], value: [{ name: 'Jane' }] }
|
|
6225
|
+
\`\`\``
|
|
5796
6226
|
},
|
|
5797
6227
|
page: {
|
|
5798
6228
|
brief: `\`\`\`typescript
|
|
@@ -5869,7 +6299,7 @@ Use \`col\` (1-12) on child fields for grid width. Row is purely for layout.
|
|
|
5869
6299
|
|
|
5870
6300
|
**Async validators:**
|
|
5871
6301
|
\`\`\`typescript
|
|
5872
|
-
validators: [{ type: '
|
|
6302
|
+
validators: [{ type: 'async', functionName: 'checkEmailAvailable' }]
|
|
5873
6303
|
\`\`\`
|
|
5874
6304
|
|
|
5875
6305
|
**Custom error messages (validationMessages at FIELD level):**
|
|
@@ -5956,9 +6386,16 @@ See topic \`external-data\` for full documentation.`
|
|
|
5956
6386
|
|
|
5957
6387
|
// Logic block (for conditions, debounce, etc.)
|
|
5958
6388
|
logic: [{ type: 'derivation', expression: 'formValue.firstName + " " + formValue.lastName' }]
|
|
6389
|
+
|
|
6390
|
+
// Stop on user edit (with optional re-engagement)
|
|
6391
|
+
logic: [{ type: 'derivation', expression: '...', stopOnUserOverride: true, reEngageOnDependencyChange: true }]
|
|
6392
|
+
|
|
6393
|
+
// HTTP derivation (server-driven values)
|
|
6394
|
+
logic: [{ type: 'derivation', source: 'http', http: { url: '/api/rate', queryParams: { from: 'formValue.currency' } }, responseExpression: 'response.rate', dependsOn: ['currency'] }]
|
|
5959
6395
|
\`\`\`
|
|
5960
6396
|
Derivation is always defined ON the field that receives the computed value (self-targeting).
|
|
5961
|
-
**Variables:** \`formValue\`, \`fieldValue\`, \`externalData\` (see topic:
|
|
6397
|
+
**Variables:** \`formValue\`, \`fieldValue\`, \`fieldState\`, \`formFieldState\`, \`externalData\` (see topic: expression-variables)
|
|
6398
|
+
**For deriving component properties** (minDate, options, label): use \`targetProperty\` (see topic \`property-derivation\`)`,
|
|
5962
6399
|
full: `# Value Derivation (Computed Fields)
|
|
5963
6400
|
|
|
5964
6401
|
Derivations are always defined ON the field that receives the computed value (self-targeting).
|
|
@@ -6020,39 +6457,172 @@ Derivations are always defined ON the field that receives the computed value (se
|
|
|
6020
6457
|
}
|
|
6021
6458
|
\`\`\`
|
|
6022
6459
|
|
|
6023
|
-
|
|
6024
|
-
- \`formValue\`: Complete form value object
|
|
6025
|
-
- \`fieldValue\`: This field's current value
|
|
6026
|
-
- \`externalData\`: External application state (user roles, feature flags, etc.)
|
|
6460
|
+
## HTTP Derivation (Server-Driven Values)
|
|
6027
6461
|
|
|
6028
|
-
|
|
6029
|
-
\`\`\`typescript
|
|
6030
|
-
derivation: 'formValue.address?.city' // Optional chaining
|
|
6031
|
-
derivation: 'formValue.items?.[0]?.name' // Array access
|
|
6032
|
-
derivation: 'formValue.discount ?? 0' // Nullish coalescing
|
|
6033
|
-
derivation: 'formValue.price * (1 - externalData.discountRate)' // External data
|
|
6034
|
-
\`\`\`
|
|
6462
|
+
Fetch derived values from an HTTP endpoint. Requires \`dependsOn\` (explicit) and \`responseExpression\`.
|
|
6035
6463
|
|
|
6036
|
-
**Complex logic (IIFE):**
|
|
6037
6464
|
\`\`\`typescript
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6465
|
+
{
|
|
6466
|
+
key: 'exchangeRate',
|
|
6467
|
+
type: 'input',
|
|
6468
|
+
label: 'Exchange Rate',
|
|
6469
|
+
readonly: true,
|
|
6470
|
+
logic: [{
|
|
6471
|
+
type: 'derivation',
|
|
6472
|
+
source: 'http',
|
|
6473
|
+
http: {
|
|
6474
|
+
url: '/api/exchange-rate',
|
|
6475
|
+
method: 'GET',
|
|
6476
|
+
queryParams: {
|
|
6477
|
+
from: 'formValue.sourceCurrency',
|
|
6478
|
+
to: 'formValue.targetCurrency'
|
|
6479
|
+
}
|
|
6480
|
+
},
|
|
6481
|
+
responseExpression: 'response.rate', // Extract value from response
|
|
6482
|
+
dependsOn: ['sourceCurrency', 'targetCurrency'], // Required for HTTP
|
|
6483
|
+
trigger: 'debounced',
|
|
6484
|
+
debounceMs: 500 // Optional, defaults to 300
|
|
6485
|
+
}]
|
|
6486
|
+
}
|
|
6048
6487
|
\`\`\`
|
|
6049
|
-
\u26A0\uFE0F **NOT in props**, NOT just strings, MUST have label+value`,
|
|
6050
|
-
full: `# Options Format (select, radio, multi-checkbox)
|
|
6051
6488
|
|
|
6052
|
-
**
|
|
6053
|
-
|
|
6489
|
+
**Key rules:**
|
|
6490
|
+
- \`source: 'http'\` is **required** to select HTTP derivation mode
|
|
6491
|
+
- \`dependsOn\` is **required** (prevents wildcard triggering on every keystroke)
|
|
6492
|
+
- \`responseExpression\` is **required** (extracts value from HTTP response body)
|
|
6493
|
+
- \`queryParams\` values are expressions evaluated by ExpressionParser
|
|
6494
|
+
- Requests are automatically debounced and cancelled (switchMap)
|
|
6495
|
+
- HTTP errors are caught and logged (stream continues)
|
|
6496
|
+
- Array fields (\`items.$.rate\`) are NOT supported for HTTP derivations
|
|
6497
|
+
- Works with \`stopOnUserOverride\` and \`condition\`
|
|
6054
6498
|
|
|
6055
|
-
|
|
6499
|
+
## Async Derivation (Custom Async Functions)
|
|
6500
|
+
|
|
6501
|
+
Call a registered async function (Promise or Observable) to derive values. Requires \`dependsOn\` (explicit).
|
|
6502
|
+
|
|
6503
|
+
\`\`\`typescript
|
|
6504
|
+
{
|
|
6505
|
+
key: 'suggestedPrice',
|
|
6506
|
+
type: 'input',
|
|
6507
|
+
label: 'Suggested Price',
|
|
6508
|
+
readonly: true,
|
|
6509
|
+
logic: [{
|
|
6510
|
+
type: 'derivation',
|
|
6511
|
+
source: 'asyncFunction',
|
|
6512
|
+
asyncFunctionName: 'fetchSuggestedPrice',
|
|
6513
|
+
dependsOn: ['productId', 'quantity'] // Required for async
|
|
6514
|
+
}]
|
|
6515
|
+
}
|
|
6516
|
+
\`\`\`
|
|
6517
|
+
|
|
6518
|
+
Register the function in \`customFnConfig.asyncDerivations\`:
|
|
6519
|
+
|
|
6520
|
+
\`\`\`typescript
|
|
6521
|
+
customFnConfig: {
|
|
6522
|
+
asyncDerivations: {
|
|
6523
|
+
fetchSuggestedPrice: async (context) => {
|
|
6524
|
+
const response = await fetch(\`/api/price?product=\${context.formValue.productId}\`);
|
|
6525
|
+
const data = await response.json();
|
|
6526
|
+
return data.suggestedPrice;
|
|
6527
|
+
},
|
|
6528
|
+
// Observable-based alternative
|
|
6529
|
+
lookupAddress: (context) => {
|
|
6530
|
+
return addressService.lookup(context.formValue.zipCode).pipe(
|
|
6531
|
+
map(result => result.formattedAddress)
|
|
6532
|
+
);
|
|
6533
|
+
},
|
|
6534
|
+
}
|
|
6535
|
+
}
|
|
6536
|
+
\`\`\`
|
|
6537
|
+
|
|
6538
|
+
**Key rules:**
|
|
6539
|
+
- \`asyncFunctionName\` is mutually exclusive with \`expression\`, \`value\`, \`functionName\`, and \`http\`
|
|
6540
|
+
- \`dependsOn\` is **required** (prevents triggering on every form change)
|
|
6541
|
+
- Wildcards (\`'*'\`) are **not allowed** in \`dependsOn\`
|
|
6542
|
+
- The function returns the value directly (no \`responseExpression\` needed)
|
|
6543
|
+
- Functions handle their own I/O (no HttpClient provided)
|
|
6544
|
+
- Requests are automatically debounced and cancelled (switchMap)
|
|
6545
|
+
- Errors are caught and logged (previous value retained)
|
|
6546
|
+
- Array fields (\`items.$.rate\`) are NOT supported
|
|
6547
|
+
- Works with \`stopOnUserOverride\` and \`condition\`
|
|
6548
|
+
|
|
6549
|
+
## Stop on User Override
|
|
6550
|
+
|
|
6551
|
+
When \`stopOnUserOverride: true\`, the derivation stops running after the user manually edits the target field:
|
|
6552
|
+
|
|
6553
|
+
\`\`\`typescript
|
|
6554
|
+
{
|
|
6555
|
+
key: 'fullName',
|
|
6556
|
+
type: 'input',
|
|
6557
|
+
label: 'Full Name',
|
|
6558
|
+
logic: [{
|
|
6559
|
+
type: 'derivation',
|
|
6560
|
+
expression: 'formValue.firstName + " " + formValue.lastName',
|
|
6561
|
+
stopOnUserOverride: true // Derivation stops after user manually edits fullName
|
|
6562
|
+
}]
|
|
6563
|
+
}
|
|
6564
|
+
\`\`\`
|
|
6565
|
+
|
|
6566
|
+
### Re-engagement on Dependency Change
|
|
6567
|
+
|
|
6568
|
+
Combine \`reEngageOnDependencyChange: true\` with \`stopOnUserOverride\` to clear user overrides when dependencies change:
|
|
6569
|
+
|
|
6570
|
+
\`\`\`typescript
|
|
6571
|
+
{
|
|
6572
|
+
key: 'fullName',
|
|
6573
|
+
type: 'input',
|
|
6574
|
+
label: 'Full Name',
|
|
6575
|
+
logic: [{
|
|
6576
|
+
type: 'derivation',
|
|
6577
|
+
expression: 'formValue.firstName + " " + formValue.lastName',
|
|
6578
|
+
stopOnUserOverride: true,
|
|
6579
|
+
reEngageOnDependencyChange: true // Clears user override when firstName/lastName changes
|
|
6580
|
+
}]
|
|
6581
|
+
}
|
|
6582
|
+
\`\`\`
|
|
6583
|
+
|
|
6584
|
+
**Use cases:**
|
|
6585
|
+
- Allow users to customize computed values
|
|
6586
|
+
- Reset customizations when source data changes
|
|
6587
|
+
- Smart defaults that defer to user input
|
|
6588
|
+
|
|
6589
|
+
**Variables in expressions:**
|
|
6590
|
+
- \`formValue\`: Complete form value object
|
|
6591
|
+
- \`fieldValue\`: This field's current value
|
|
6592
|
+
- \`fieldState\`: Current field's state (touched, dirty, valid, etc.)
|
|
6593
|
+
- \`formFieldState\`: All fields' states keyed by field key
|
|
6594
|
+
- \`externalData\`: External application state (user roles, feature flags, etc.)
|
|
6595
|
+
|
|
6596
|
+
**Nested access:**
|
|
6597
|
+
\`\`\`typescript
|
|
6598
|
+
derivation: 'formValue.address?.city' // Optional chaining
|
|
6599
|
+
derivation: 'formValue.items?.[0]?.name' // Array access
|
|
6600
|
+
derivation: 'formValue.discount ?? 0' // Nullish coalescing
|
|
6601
|
+
derivation: 'formValue.price * (1 - externalData.discountRate)' // External data
|
|
6602
|
+
\`\`\`
|
|
6603
|
+
|
|
6604
|
+
**Complex logic (IIFE):**
|
|
6605
|
+
\`\`\`typescript
|
|
6606
|
+
derivation: \`(() => {
|
|
6607
|
+
const qty = formValue.quantity || 0;
|
|
6608
|
+
const price = formValue.unitPrice || 0;
|
|
6609
|
+
return qty * price;
|
|
6610
|
+
})()\`
|
|
6611
|
+
\`\`\`
|
|
6612
|
+
|
|
6613
|
+
**See also:** \`property-derivation\` topic for deriving component properties (minDate, options, label, etc.)`
|
|
6614
|
+
},
|
|
6615
|
+
"options-format": {
|
|
6616
|
+
brief: `\`\`\`typescript
|
|
6617
|
+
options: [{ label: 'Display Text', value: 'actual-value' }] // AT FIELD LEVEL!
|
|
6618
|
+
\`\`\`
|
|
6619
|
+
\u26A0\uFE0F **NOT in props**, NOT just strings, MUST have label+value`,
|
|
6620
|
+
full: `# Options Format (select, radio, multi-checkbox)
|
|
6621
|
+
|
|
6622
|
+
**Required Format:**
|
|
6623
|
+
Options MUST be an array of objects with \`label\` and \`value\` at **FIELD level**:
|
|
6624
|
+
|
|
6625
|
+
\`\`\`typescript
|
|
6056
6626
|
{
|
|
6057
6627
|
key: 'country',
|
|
6058
6628
|
type: 'select',
|
|
@@ -6084,6 +6654,8 @@ Wrong: Wrong property names
|
|
|
6084
6654
|
"expression-variables": {
|
|
6085
6655
|
brief: `**formValue** - complete form object: \`formValue.fieldName\`, \`formValue.address?.city\`
|
|
6086
6656
|
**fieldValue** - current field's value (in custom validators)
|
|
6657
|
+
**fieldState** - current field state: \`fieldState.touched\`, \`fieldState.valid\`, \`fieldState.dirty\`
|
|
6658
|
+
**formFieldState** - all field states: \`formFieldState.email.valid\`, \`formFieldState.password.pristine\`
|
|
6087
6659
|
**externalData** - external signals: \`externalData.userRole\`, \`externalData.featureFlags.enabled\``,
|
|
6088
6660
|
full: `# Variables in Expressions
|
|
6089
6661
|
|
|
@@ -6093,6 +6665,8 @@ Wrong: Wrong property names
|
|
|
6093
6665
|
|----------|------|--------------|-------------|
|
|
6094
6666
|
| \`formValue\` | object | Derivations, conditions, custom validators | Complete form values as nested object |
|
|
6095
6667
|
| \`fieldValue\` | any | Custom validator expressions only | Current field's value |
|
|
6668
|
+
| \`fieldState\` | object | Derivations, conditions | Current field's state (touched, dirty, valid, etc.) |
|
|
6669
|
+
| \`formFieldState\` | object | Derivations, conditions | All fields' states keyed by field key |
|
|
6096
6670
|
| \`externalData\` | object | Derivations, conditions | External application state (from \`externalData\` in FormConfig) |
|
|
6097
6671
|
|
|
6098
6672
|
## Accessing Values
|
|
@@ -6115,6 +6689,56 @@ expression: 'formValue.contacts?.[0]?.name'
|
|
|
6115
6689
|
expression: '(formValue.items?.length || 0) + " items"'
|
|
6116
6690
|
\`\`\`
|
|
6117
6691
|
|
|
6692
|
+
### Field State Access
|
|
6693
|
+
|
|
6694
|
+
\`fieldState\` provides access to the current field's state properties:
|
|
6695
|
+
|
|
6696
|
+
\`\`\`typescript
|
|
6697
|
+
// Check if current field has been touched
|
|
6698
|
+
expression: 'fieldState.touched ? "Modified" : "Untouched"'
|
|
6699
|
+
|
|
6700
|
+
// Check if current field is valid
|
|
6701
|
+
expression: 'fieldState.valid && formValue.email'
|
|
6702
|
+
|
|
6703
|
+
// Check if current field is dirty
|
|
6704
|
+
expression: 'fieldState.dirty ? "Changed from initial" : "Unchanged"'
|
|
6705
|
+
\`\`\`
|
|
6706
|
+
|
|
6707
|
+
**Available \`fieldState\` properties:**
|
|
6708
|
+
- \`touched\`: boolean - Field has been focused and blurred
|
|
6709
|
+
- \`dirty\`: boolean - Value differs from initial value
|
|
6710
|
+
- \`pristine\`: boolean - Value matches initial value
|
|
6711
|
+
- \`valid\`: boolean - No validation errors
|
|
6712
|
+
- \`invalid\`: boolean - Has validation errors
|
|
6713
|
+
- \`pending\`: boolean - Async validation in progress
|
|
6714
|
+
- \`hidden\`: boolean - Field is currently hidden
|
|
6715
|
+
- \`readonly\`: boolean - Field is currently readonly
|
|
6716
|
+
- \`disabled\`: boolean - Field is currently disabled
|
|
6717
|
+
|
|
6718
|
+
### Form Field State Access
|
|
6719
|
+
|
|
6720
|
+
\`formFieldState\` provides access to any field's state by key:
|
|
6721
|
+
|
|
6722
|
+
\`\`\`typescript
|
|
6723
|
+
// Check if email field is valid
|
|
6724
|
+
expression: 'formFieldState.email?.valid'
|
|
6725
|
+
|
|
6726
|
+
// Conditional logic based on multiple field states
|
|
6727
|
+
expression: 'formFieldState.password?.valid && formFieldState.confirmPassword?.touched'
|
|
6728
|
+
|
|
6729
|
+
// Check if any field is invalid
|
|
6730
|
+
expression: '!formFieldState.email?.valid || !formFieldState.password?.valid'
|
|
6731
|
+
\`\`\`
|
|
6732
|
+
|
|
6733
|
+
**Nested field paths:**
|
|
6734
|
+
\`\`\`typescript
|
|
6735
|
+
// Access nested group field state
|
|
6736
|
+
expression: 'formFieldState["address.city"]?.dirty'
|
|
6737
|
+
|
|
6738
|
+
// Access array item field state
|
|
6739
|
+
expression: 'formFieldState["contacts.0.name"]?.valid'
|
|
6740
|
+
\`\`\`
|
|
6741
|
+
|
|
6118
6742
|
### External Data Access
|
|
6119
6743
|
\`\`\`typescript
|
|
6120
6744
|
expression: "externalData.userRole === 'admin'"
|
|
@@ -6122,11 +6746,202 @@ expression: 'externalData.featureFlags.advancedMode === true'
|
|
|
6122
6746
|
expression: 'externalData.permissions.includes("edit")'
|
|
6123
6747
|
\`\`\`
|
|
6124
6748
|
|
|
6749
|
+
## Common Patterns
|
|
6750
|
+
|
|
6751
|
+
### Conditional derivation based on field state
|
|
6752
|
+
\`\`\`typescript
|
|
6753
|
+
// Only compute if other field is valid
|
|
6754
|
+
expression: 'formFieldState.email?.valid ? formValue.email.toLowerCase() : ""'
|
|
6755
|
+
|
|
6756
|
+
// Show warning if field is touched but invalid
|
|
6757
|
+
expression: 'fieldState.touched && fieldState.invalid ? "Please fix errors" : ""'
|
|
6758
|
+
\`\`\`
|
|
6759
|
+
|
|
6760
|
+
### Validation-aware derivations
|
|
6761
|
+
\`\`\`typescript
|
|
6762
|
+
// Only use value if source field is valid
|
|
6763
|
+
expression: 'formFieldState.price?.valid ? formValue.price * 1.2 : null'
|
|
6764
|
+
\`\`\`
|
|
6765
|
+
|
|
6125
6766
|
## Safety Tips
|
|
6126
6767
|
|
|
6127
|
-
1. **Always use optional chaining** (\`?.\`) for nested paths
|
|
6768
|
+
1. **Always use optional chaining** (\`?.\`) for nested paths and field state access
|
|
6128
6769
|
2. **Provide defaults** for potentially undefined values: \`formValue.count || 0\`
|
|
6129
|
-
3. **Use nullish coalescing** (\`??\`) when 0 or empty string are valid: \`formValue.score ?? 'N/A'
|
|
6770
|
+
3. **Use nullish coalescing** (\`??\`) when 0 or empty string are valid: \`formValue.score ?? 'N/A'\`
|
|
6771
|
+
4. **Check field existence** when using \`formFieldState\`: \`formFieldState.fieldKey?.valid\``
|
|
6772
|
+
},
|
|
6773
|
+
"property-derivation": {
|
|
6774
|
+
brief: `\`\`\`typescript
|
|
6775
|
+
// Derive endDate's minDate from startDate value
|
|
6776
|
+
logic: [{ type: 'derivation', targetProperty: 'minDate', expression: 'formValue.startDate' }]
|
|
6777
|
+
|
|
6778
|
+
// Derive select options from another field
|
|
6779
|
+
logic: [{ type: 'derivation', targetProperty: 'options', functionName: 'getCitiesForCountry', dependsOn: ['country'] }]
|
|
6780
|
+
\`\`\`
|
|
6781
|
+
Unlike value derivation (which sets the field's **value**), property derivation sets **component input properties** like minDate, options, label, placeholder, props.appearance.`,
|
|
6782
|
+
full: `# Property Derivation (Dynamic Field Properties)
|
|
6783
|
+
|
|
6784
|
+
Reactively derive field component properties based on form values. Unlike value derivations (which compute the field's value), property derivations compute **component input properties** like \`minDate\`, \`options\`, \`label\`, \`placeholder\`, \`props.appearance\`, etc.
|
|
6785
|
+
|
|
6786
|
+
Property derivations are self-targeting: the logic is placed on the field whose property should be derived.
|
|
6787
|
+
|
|
6788
|
+
## Basic Syntax
|
|
6789
|
+
|
|
6790
|
+
\`\`\`typescript
|
|
6791
|
+
{
|
|
6792
|
+
key: 'endDate',
|
|
6793
|
+
type: 'datepicker',
|
|
6794
|
+
label: 'End Date',
|
|
6795
|
+
logic: [{
|
|
6796
|
+
type: 'derivation',
|
|
6797
|
+
targetProperty: 'minDate',
|
|
6798
|
+
expression: 'formValue.startDate',
|
|
6799
|
+
}]
|
|
6800
|
+
}
|
|
6801
|
+
\`\`\`
|
|
6802
|
+
|
|
6803
|
+
When \`startDate\` changes, the \`minDate\` property on the \`endDate\` datepicker is automatically updated.
|
|
6804
|
+
|
|
6805
|
+
## Derivation Sources
|
|
6806
|
+
|
|
6807
|
+
### Expression-Based
|
|
6808
|
+
\`\`\`typescript
|
|
6809
|
+
logic: [{
|
|
6810
|
+
type: 'derivation',
|
|
6811
|
+
targetProperty: 'minDate',
|
|
6812
|
+
expression: 'formValue.startDate',
|
|
6813
|
+
}]
|
|
6814
|
+
\`\`\`
|
|
6815
|
+
|
|
6816
|
+
### Static Value
|
|
6817
|
+
\`\`\`typescript
|
|
6818
|
+
logic: [{
|
|
6819
|
+
type: 'derivation',
|
|
6820
|
+
targetProperty: 'label',
|
|
6821
|
+
value: 'Mobile Phone',
|
|
6822
|
+
condition: { type: 'fieldValue', fieldPath: 'contactType', operator: 'equals', value: 'mobile' },
|
|
6823
|
+
}]
|
|
6824
|
+
\`\`\`
|
|
6825
|
+
|
|
6826
|
+
### Custom Function
|
|
6827
|
+
\`\`\`typescript
|
|
6828
|
+
// Register in customFnConfig
|
|
6829
|
+
customFnConfig: {
|
|
6830
|
+
derivations: {
|
|
6831
|
+
getCitiesForCountry: (ctx) => {
|
|
6832
|
+
const cities = { 'US': [{ label: 'NYC', value: 'nyc' }], 'DE': [{ label: 'Berlin', value: 'berlin' }] };
|
|
6833
|
+
return cities[ctx.formValue.country as string] ?? [];
|
|
6834
|
+
},
|
|
6835
|
+
},
|
|
6836
|
+
}
|
|
6837
|
+
|
|
6838
|
+
// Use in field config
|
|
6839
|
+
{
|
|
6840
|
+
key: 'city',
|
|
6841
|
+
type: 'select',
|
|
6842
|
+
label: 'City',
|
|
6843
|
+
logic: [{
|
|
6844
|
+
type: 'derivation',
|
|
6845
|
+
targetProperty: 'options',
|
|
6846
|
+
functionName: 'getCitiesForCountry',
|
|
6847
|
+
dependsOn: ['country'],
|
|
6848
|
+
}]
|
|
6849
|
+
}
|
|
6850
|
+
\`\`\`
|
|
6851
|
+
|
|
6852
|
+
## Supported Target Properties
|
|
6853
|
+
|
|
6854
|
+
### Simple Properties (1 level)
|
|
6855
|
+
\`minDate\`, \`maxDate\`, \`options\`, \`label\`, \`placeholder\`, \`hint\`, \`disabled\`, \`readonly\`, \`required\`, \`rows\`, \`cols\`, etc.
|
|
6856
|
+
|
|
6857
|
+
### Nested Properties (max 2 levels)
|
|
6858
|
+
\`props.appearance\`, \`props.color\`, \`props.variant\`, \`meta.autocomplete\`, etc.
|
|
6859
|
+
|
|
6860
|
+
\u26A0\uFE0F Deeper nesting (3+ levels) is **not supported**.
|
|
6861
|
+
|
|
6862
|
+
## Trigger Timing
|
|
6863
|
+
|
|
6864
|
+
| Trigger | Description | Use Case |
|
|
6865
|
+
|-------------|---------------------------------------|-----------------------------------|
|
|
6866
|
+
| \`onChange\` | Immediately on change (default) | Date constraints, dynamic options |
|
|
6867
|
+
| \`debounced\` | After value stabilizes | Expensive lookups, API calls |
|
|
6868
|
+
|
|
6869
|
+
\`\`\`typescript
|
|
6870
|
+
logic: [{
|
|
6871
|
+
type: 'derivation',
|
|
6872
|
+
targetProperty: 'options',
|
|
6873
|
+
functionName: 'searchProducts',
|
|
6874
|
+
trigger: 'debounced',
|
|
6875
|
+
debounceMs: 300,
|
|
6876
|
+
dependsOn: ['searchQuery'],
|
|
6877
|
+
}]
|
|
6878
|
+
\`\`\`
|
|
6879
|
+
|
|
6880
|
+
## Conditional Property Derivation
|
|
6881
|
+
|
|
6882
|
+
\`\`\`typescript
|
|
6883
|
+
logic: [{
|
|
6884
|
+
type: 'derivation',
|
|
6885
|
+
targetProperty: 'label',
|
|
6886
|
+
value: 'Company Email',
|
|
6887
|
+
condition: { type: 'fieldValue', fieldPath: 'accountType', operator: 'equals', value: 'business' },
|
|
6888
|
+
}]
|
|
6889
|
+
\`\`\`
|
|
6890
|
+
|
|
6891
|
+
## Array Fields
|
|
6892
|
+
|
|
6893
|
+
Inside arrays, \`formValue\` is scoped to the current array item:
|
|
6894
|
+
\`\`\`typescript
|
|
6895
|
+
{
|
|
6896
|
+
key: 'lineItems',
|
|
6897
|
+
type: 'array',
|
|
6898
|
+
fields: [{
|
|
6899
|
+
key: 'item',
|
|
6900
|
+
type: 'group',
|
|
6901
|
+
fields: [
|
|
6902
|
+
{ key: 'startDate', type: 'datepicker', label: 'Start' },
|
|
6903
|
+
{
|
|
6904
|
+
key: 'endDate',
|
|
6905
|
+
type: 'datepicker',
|
|
6906
|
+
label: 'End',
|
|
6907
|
+
logic: [{
|
|
6908
|
+
type: 'derivation',
|
|
6909
|
+
targetProperty: 'minDate',
|
|
6910
|
+
expression: 'formValue.startDate', // Scoped to current array item
|
|
6911
|
+
}]
|
|
6912
|
+
}
|
|
6913
|
+
]
|
|
6914
|
+
}]
|
|
6915
|
+
}
|
|
6916
|
+
\`\`\`
|
|
6917
|
+
|
|
6918
|
+
## Key Differences from Value Derivation
|
|
6919
|
+
|
|
6920
|
+
| Aspect | Value Derivation | Property Derivation |
|
|
6921
|
+
|--------|-----------------|---------------------|
|
|
6922
|
+
| Sets | Field's form value | Component input property |
|
|
6923
|
+
| Type | \`type: 'derivation'\` | \`type: 'derivation'\` |
|
|
6924
|
+
| Target | Implicit (self) | \`targetProperty: 'minDate'\` |
|
|
6925
|
+
| Shorthand | \`derivation: 'expr'\` | None (must use logic block) |
|
|
6926
|
+
| Chaining | Topologically sorted | No chaining (single pass) |
|
|
6927
|
+
| Functions | \`customFnConfig.derivations\` | \`customFnConfig.derivations\` |
|
|
6928
|
+
|
|
6929
|
+
## DerivationLogicConfig Interface (with targetProperty)
|
|
6930
|
+
|
|
6931
|
+
\`\`\`typescript
|
|
6932
|
+
interface DerivationLogicConfig {
|
|
6933
|
+
type: 'derivation';
|
|
6934
|
+
targetProperty: string; // Property to set (e.g., 'minDate', 'options')
|
|
6935
|
+
debugName?: string; // For logging/debugging
|
|
6936
|
+
condition?: ConditionalExpression | boolean; // When to apply (default: true)
|
|
6937
|
+
value?: unknown; // Static value (mutually exclusive)
|
|
6938
|
+
expression?: string; // JavaScript expression (mutually exclusive)
|
|
6939
|
+
functionName?: string; // Custom function name (mutually exclusive)
|
|
6940
|
+
dependsOn?: string[]; // Explicit field dependencies
|
|
6941
|
+
trigger?: 'onChange' | 'debounced'; // Evaluation timing (default: 'onChange')
|
|
6942
|
+
debounceMs?: number; // Debounce duration (default: 500)
|
|
6943
|
+
}
|
|
6944
|
+
\`\`\``
|
|
6130
6945
|
},
|
|
6131
6946
|
"external-data": {
|
|
6132
6947
|
brief: `\`\`\`typescript
|
|
@@ -6227,7 +7042,7 @@ logic: [{
|
|
|
6227
7042
|
},
|
|
6228
7043
|
"async-validators": {
|
|
6229
7044
|
brief: `\`\`\`typescript
|
|
6230
|
-
validators: [{ type: '
|
|
7045
|
+
validators: [{ type: 'async', functionName: 'checkEmailAvailable' }]
|
|
6231
7046
|
\`\`\`
|
|
6232
7047
|
Define function, register in provideDynamicForm, use in config.`,
|
|
6233
7048
|
full: `# Async Validators (Complete Example)
|
|
@@ -6267,7 +7082,7 @@ provideDynamicForm({
|
|
|
6267
7082
|
type: 'input',
|
|
6268
7083
|
label: 'Email',
|
|
6269
7084
|
validators: [
|
|
6270
|
-
{ type: '
|
|
7085
|
+
{ type: 'async', functionName: 'checkEmailAvailable' }
|
|
6271
7086
|
],
|
|
6272
7087
|
validationMessages: { emailTaken: 'This email is already registered' }
|
|
6273
7088
|
}
|
|
@@ -6361,35 +7176,37 @@ validators: [{
|
|
|
6361
7176
|
},
|
|
6362
7177
|
// ========== PATTERNS & RULES ==========
|
|
6363
7178
|
"logic-matrix": {
|
|
6364
|
-
brief: `**Containers:** group/row/array
|
|
6365
|
-
**Page:** hidden only | **Value fields:** all logic types | **Buttons:** hidden, disabled`,
|
|
7179
|
+
brief: `**Containers:** group/row/array support only \`hidden\` logic (same as pages). For other logic types, apply to children.
|
|
7180
|
+
**Page:** hidden only | **Value fields:** all logic types incl. derivation + targetProperty | **Buttons:** hidden, disabled`,
|
|
6366
7181
|
full: `# Logic Support Matrix
|
|
6367
7182
|
|
|
6368
|
-
| Field Type | hidden | disabled | required | readonly | derivation |
|
|
6369
|
-
|
|
6370
|
-
| page | Y | N | N | N | N |
|
|
6371
|
-
| group |
|
|
6372
|
-
| row |
|
|
6373
|
-
| array |
|
|
6374
|
-
| input | Y | Y | Y | Y | Y |
|
|
6375
|
-
| select | Y | Y | Y | Y | Y |
|
|
6376
|
-
| checkbox | Y | Y | Y | Y | Y |
|
|
6377
|
-
| radio | Y | Y | Y | Y | Y |
|
|
6378
|
-
| multi-checkbox | Y | Y | Y | Y | Y |
|
|
6379
|
-
| textarea | Y | Y | Y | Y | Y |
|
|
6380
|
-
| datepicker | Y | Y | Y | Y | Y |
|
|
6381
|
-
| toggle | Y | Y | Y | Y | Y |
|
|
6382
|
-
| slider | Y | Y | Y | Y | Y |
|
|
6383
|
-
| hidden | N | N | N | N | N |
|
|
6384
|
-
| text | Y | N | N | N | N |
|
|
6385
|
-
| button/submit | Y | Y | N | N | N |
|
|
6386
|
-
| next/previous | Y | Y | N | N | N |
|
|
7183
|
+
| Field Type | hidden | disabled | required | readonly | derivation | derivation+targetProperty |
|
|
7184
|
+
|----------------|--------|----------|----------|----------|------------|--------------------|
|
|
7185
|
+
| page | Y | N | N | N | N | N |
|
|
7186
|
+
| group | Y | N | N | N | N | N |
|
|
7187
|
+
| row | Y | N | N | N | N | N |
|
|
7188
|
+
| array | Y | N | N | N | N | N |
|
|
7189
|
+
| input | Y | Y | Y | Y | Y | Y |
|
|
7190
|
+
| select | Y | Y | Y | Y | Y | Y |
|
|
7191
|
+
| checkbox | Y | Y | Y | Y | Y | Y |
|
|
7192
|
+
| radio | Y | Y | Y | Y | Y | Y |
|
|
7193
|
+
| multi-checkbox | Y | Y | Y | Y | Y | Y |
|
|
7194
|
+
| textarea | Y | Y | Y | Y | Y | Y |
|
|
7195
|
+
| datepicker | Y | Y | Y | Y | Y | Y |
|
|
7196
|
+
| toggle | Y | Y | Y | Y | Y | Y |
|
|
7197
|
+
| slider | Y | Y | Y | Y | Y | Y |
|
|
7198
|
+
| hidden | N | N | N | N | N | N |
|
|
7199
|
+
| text | Y | N | N | N | N | Y |
|
|
7200
|
+
| button/submit | Y | Y | N | N | N | N |
|
|
7201
|
+
| next/previous | Y | Y | N | N | N | N |
|
|
6387
7202
|
|
|
6388
7203
|
**Key takeaways:**
|
|
6389
|
-
- Containers (group, row, array)
|
|
7204
|
+
- Containers (group, row, array) support only \`hidden\` logic type (same as pages) for conditional visibility. For other logic types, apply to child fields instead.
|
|
6390
7205
|
- Page only supports \`hidden\` logic (for conditional pages)
|
|
6391
7206
|
- Hidden fields have NO logic support at all
|
|
6392
|
-
- Value fields (input, select, etc.) support ALL logic types
|
|
7207
|
+
- Value fields (input, select, etc.) support ALL logic types including \`derivation\` with \`targetProperty\`
|
|
7208
|
+
- \`derivation\` with \`targetProperty\` sets component input properties (e.g., \`minDate\`, \`options\`), NOT the field's form value
|
|
7209
|
+
- Text (display) fields support \`derivation\` with \`targetProperty\` for dynamic labels`
|
|
6393
7210
|
},
|
|
6394
7211
|
"context-api": {
|
|
6395
7212
|
brief: `**Expressions:** \`formValue.field\`, \`fieldValue\`
|
|
@@ -6442,20 +7259,21 @@ provideDynamicForm({
|
|
|
6442
7259
|
brief: `| Container | Label? | Logic? | Notes |
|
|
6443
7260
|
|-----------|--------|--------|-------|
|
|
6444
7261
|
| page | NO | hidden only | Nav buttons INSIDE |
|
|
6445
|
-
| group | NO |
|
|
6446
|
-
| array | NO |
|
|
6447
|
-
| row | NO |
|
|
7262
|
+
| group | NO | hidden only | Creates nested object |
|
|
7263
|
+
| array | NO | hidden only | Two APIs: \`template\`+\`value\` (simplified) or \`fields\` (full) |
|
|
7264
|
+
| row | NO | hidden only | Layout only, no hidden fields |`,
|
|
6448
7265
|
full: `# Container Rules
|
|
6449
7266
|
|
|
6450
7267
|
| Container | Label? | Logic? | Allowed Children | Notes |
|
|
6451
7268
|
|-----------|--------|--------|------------------|-------|
|
|
6452
7269
|
| page | NO | YES (hidden only) | rows, groups, arrays, leaf fields, buttons | Nav buttons go INSIDE |
|
|
6453
|
-
| group | NO |
|
|
6454
|
-
| array | NO |
|
|
6455
|
-
| row | NO |
|
|
7270
|
+
| group | NO | YES (hidden only) | rows, leaf fields (NOT pages, groups) | Creates nested object |
|
|
7271
|
+
| array | NO | YES (hidden only) | rows, groups, leaf fields (NOT pages, arrays) | Two APIs: \`template\`+\`value\` (simplified) or \`fields\` (full) |
|
|
7272
|
+
| row | NO | YES (hidden only) | groups, arrays, leaf fields (NOT hidden, pages, rows) | Layout only |
|
|
6456
7273
|
|
|
6457
7274
|
**CRITICAL:**
|
|
6458
|
-
-
|
|
7275
|
+
- All containers (page, group, row, array) support only the \`hidden\` logic type for conditional visibility.
|
|
7276
|
+
- For other logic types (disabled, required, readonly, derivation), apply them to child fields instead.
|
|
6459
7277
|
- Hidden fields cannot be inside rows - place them at page/form level.
|
|
6460
7278
|
- Pages cannot be nested - ALL top-level fields must be pages if using multi-page mode.`
|
|
6461
7279
|
},
|
|
@@ -6654,9 +7472,10 @@ const formConfig = {
|
|
|
6654
7472
|
pitfalls: {
|
|
6655
7473
|
brief: `1. Hidden field missing \`value\` - REQUIRED!
|
|
6656
7474
|
2. Hidden field with label/logic/validators - FORBIDDEN!
|
|
6657
|
-
3. Container (group/row/array)
|
|
7475
|
+
3. Container (group/row/array) only supports 'hidden' logic - other types go on children!
|
|
6658
7476
|
4. options/minValue/maxValue in props - must be at FIELD level!
|
|
6659
|
-
5. Generic button without \`event\` - use submit/next/previous
|
|
7477
|
+
5. Generic button without \`event\` - use submit/next/previous!
|
|
7478
|
+
6. Array: \`fields\` and \`template\` are mutually exclusive - use one or the other!`,
|
|
6660
7479
|
full: `# Common Pitfalls & Solutions
|
|
6661
7480
|
|
|
6662
7481
|
## 1. Hidden field missing value property
|
|
@@ -6684,16 +7503,17 @@ const formConfig = {
|
|
|
6684
7503
|
{ key: 'userId', type: 'hidden', value: '123' } // \u2705
|
|
6685
7504
|
\`\`\`
|
|
6686
7505
|
|
|
6687
|
-
## 3.
|
|
7506
|
+
## 3. Containers only support 'hidden' logic
|
|
6688
7507
|
\`\`\`typescript
|
|
6689
|
-
// WRONG - logic on group/row/array
|
|
6690
|
-
{ type: 'group', logic: [...] } // \u274C
|
|
7508
|
+
// WRONG - non-hidden logic on group/row/array
|
|
7509
|
+
{ type: 'group', logic: [{ type: 'disabled', condition: {...} }] } // \u274C
|
|
6691
7510
|
|
|
6692
|
-
// CORRECT -
|
|
7511
|
+
// CORRECT - containers support 'hidden' logic for conditional visibility
|
|
6693
7512
|
{
|
|
6694
7513
|
type: 'group',
|
|
7514
|
+
logic: [{ type: 'hidden', condition: { type: 'fieldValue', fieldPath: 'showGroup', operator: 'equals', value: false } }], // \u2705
|
|
6695
7515
|
fields: [
|
|
6696
|
-
{ key: 'city', type: 'input', label: 'City', logic: [...] } // \u2705
|
|
7516
|
+
{ key: 'city', type: 'input', label: 'City', logic: [...] } // \u2705 Other logic types on children
|
|
6697
7517
|
]
|
|
6698
7518
|
}
|
|
6699
7519
|
\`\`\`
|
|
@@ -6719,13 +7539,16 @@ const formConfig = {
|
|
|
6719
7539
|
{ type: 'button', label: 'Click', event: MyEvent } // \u2705
|
|
6720
7540
|
\`\`\`
|
|
6721
7541
|
|
|
6722
|
-
## 6. Array
|
|
7542
|
+
## 6. Array: 'fields' vs 'template' (two APIs)
|
|
6723
7543
|
\`\`\`typescript
|
|
6724
|
-
// WRONG
|
|
6725
|
-
{ type: 'array',
|
|
7544
|
+
// WRONG - mixing both APIs
|
|
7545
|
+
{ type: 'array', fields: [...], template: {...} } // \u274C mutually exclusive
|
|
6726
7546
|
|
|
6727
|
-
// CORRECT
|
|
6728
|
-
{ type: 'array',
|
|
7547
|
+
// CORRECT - Simplified API (recommended for common cases)
|
|
7548
|
+
{ type: 'array', template: { key: 'value', type: 'input', label: 'Tag' }, value: ['a', 'b'] } // \u2705
|
|
7549
|
+
|
|
7550
|
+
// CORRECT - Full API (for advanced use cases)
|
|
7551
|
+
{ type: 'array', fields: [{ key: 'tag', type: 'input', value: 'a' }] } // \u2705
|
|
6729
7552
|
\`\`\`
|
|
6730
7553
|
|
|
6731
7554
|
## 7. Type annotation loses inference
|
|
@@ -6740,7 +7563,8 @@ const config = { fields: [...] } as const satisfies FormConfig;
|
|
|
6740
7563
|
conditions: {
|
|
6741
7564
|
brief: `**Operators:** equals, notEquals, greater, less, greaterOrEqual, lessOrEqual, contains, startsWith, endsWith, matches
|
|
6742
7565
|
**Combine:** \`{ type: 'and', conditions: [...] }\` or \`{ type: 'or', conditions: [...] }\`
|
|
6743
|
-
**JavaScript:** \`{ type: 'javascript', expression: 'formValue.age >= 18' }
|
|
7566
|
+
**JavaScript:** \`{ type: 'javascript', expression: 'formValue.age >= 18' }\`
|
|
7567
|
+
**HTTP:** \`{ type: 'http', http: { url: '/api/check', queryParams: { field: 'formValue.x' } }, responseExpression: 'response.allowed' }\``,
|
|
6744
7568
|
full: `# Condition Operators & Patterns
|
|
6745
7569
|
|
|
6746
7570
|
## Available Operators
|
|
@@ -6796,6 +7620,85 @@ condition: {
|
|
|
6796
7620
|
}
|
|
6797
7621
|
\`\`\`
|
|
6798
7622
|
|
|
7623
|
+
## HTTP Conditions (Server-Driven)
|
|
7624
|
+
|
|
7625
|
+
Use \`type: 'http'\` to determine field state based on HTTP responses:
|
|
7626
|
+
|
|
7627
|
+
\`\`\`typescript
|
|
7628
|
+
// Check if user has permission (GET)
|
|
7629
|
+
condition: {
|
|
7630
|
+
type: 'http',
|
|
7631
|
+
http: {
|
|
7632
|
+
url: '/api/permissions/check',
|
|
7633
|
+
queryParams: { role: 'formValue.role' }
|
|
7634
|
+
},
|
|
7635
|
+
responseExpression: 'response.allowed',
|
|
7636
|
+
pendingValue: false, // visible while loading
|
|
7637
|
+
cacheDurationMs: 60000,
|
|
7638
|
+
debounceMs: 500 // Optional, defaults to 300
|
|
7639
|
+
}
|
|
7640
|
+
|
|
7641
|
+
// Check feature flag (POST)
|
|
7642
|
+
condition: {
|
|
7643
|
+
type: 'http',
|
|
7644
|
+
http: {
|
|
7645
|
+
url: '/api/feature-flags',
|
|
7646
|
+
method: 'POST',
|
|
7647
|
+
body: { feature: 'advancedMode' },
|
|
7648
|
+
evaluateBodyExpressions: false
|
|
7649
|
+
},
|
|
7650
|
+
responseExpression: 'response.enabled'
|
|
7651
|
+
}
|
|
7652
|
+
\`\`\`
|
|
7653
|
+
|
|
7654
|
+
**Properties:**
|
|
7655
|
+
- \`http\` (required): \`HttpRequestConfig\` \u2014 URL, method, queryParams, body, headers
|
|
7656
|
+
- \`responseExpression\` (optional): Expression to extract boolean from response (scope: \`{ response }\`). When omitted, \`!!response\` is used
|
|
7657
|
+
- \`pendingValue\` (optional, default \`false\`): Value returned while HTTP request is in-flight
|
|
7658
|
+
- \`cacheDurationMs\` (optional, default \`30000\`): TTL for cached responses
|
|
7659
|
+
- \`debounceMs\` (optional, default \`300\`): Debounce for re-evaluation when dependent form values change
|
|
7660
|
+
|
|
7661
|
+
**Important:** HTTP conditions are resolved asynchronously via signals. They cannot be used inside \`and\`/\`or\` composites (will return \`false\` with a warning). Use them as standalone conditions.
|
|
7662
|
+
|
|
7663
|
+
## Async Conditions (Custom Async Functions)
|
|
7664
|
+
|
|
7665
|
+
Use \`type: 'async'\` to determine field state based on a registered async function:
|
|
7666
|
+
|
|
7667
|
+
\`\`\`typescript
|
|
7668
|
+
// Check permission via custom service
|
|
7669
|
+
condition: {
|
|
7670
|
+
type: 'async',
|
|
7671
|
+
asyncFunctionName: 'checkPermission',
|
|
7672
|
+
pendingValue: false, // visible while loading
|
|
7673
|
+
debounceMs: 500
|
|
7674
|
+
}
|
|
7675
|
+
\`\`\`
|
|
7676
|
+
|
|
7677
|
+
Register the function in \`customFnConfig.asyncConditions\`:
|
|
7678
|
+
|
|
7679
|
+
\`\`\`typescript
|
|
7680
|
+
customFnConfig: {
|
|
7681
|
+
asyncConditions: {
|
|
7682
|
+
checkPermission: async (context) => {
|
|
7683
|
+
const response = await fetch(\`/api/permissions?role=\${context.formValue.role}\`);
|
|
7684
|
+
const data = await response.json();
|
|
7685
|
+
return data.canEdit;
|
|
7686
|
+
},
|
|
7687
|
+
// Observable-based alternative
|
|
7688
|
+
hasFeatureFlag: (context) => {
|
|
7689
|
+
return featureService.isEnabled('advancedMode').pipe(map(r => r.enabled));
|
|
7690
|
+
},
|
|
7691
|
+
}
|
|
7692
|
+
}
|
|
7693
|
+
\`\`\`
|
|
7694
|
+
|
|
7695
|
+
**Properties:**
|
|
7696
|
+
- \`asyncFunctionName\` (required): Name of the registered async condition function
|
|
7697
|
+
- \`pendingValue\` (optional, default \`false\`): Value returned while async resolution is pending
|
|
7698
|
+
- \`debounceMs\` (optional, default \`300\`): Debounce for re-evaluation
|
|
7699
|
+
|
|
7700
|
+
**Important:** Async conditions are resolved asynchronously via signals. They cannot be used inside \`and\`/\`or\` composites (will return \`false\` with a warning). Use them as standalone conditions.
|
|
7701
|
+
|
|
6799
7702
|
## Button-Only Conditions
|
|
6800
7703
|
|
|
6801
7704
|
Buttons support special form state conditions:
|
|
@@ -7131,6 +8034,12 @@ var TOPIC_ALIASES = {
|
|
|
7131
8034
|
list: "array",
|
|
7132
8035
|
dynamic: "array",
|
|
7133
8036
|
repeater: "array",
|
|
8037
|
+
// Simplified array aliases
|
|
8038
|
+
"simplified-arrays": "simplified-array",
|
|
8039
|
+
"simple-array": "simplified-array",
|
|
8040
|
+
"array-template": "simplified-array",
|
|
8041
|
+
"template-array": "simplified-array",
|
|
8042
|
+
"array-simplified": "simplified-array",
|
|
7134
8043
|
layout: "row",
|
|
7135
8044
|
columns: "row",
|
|
7136
8045
|
grid: "row",
|
|
@@ -7148,6 +8057,15 @@ var TOPIC_ALIASES = {
|
|
|
7148
8057
|
computed: "derivation",
|
|
7149
8058
|
calculate: "derivation",
|
|
7150
8059
|
expression: "derivation",
|
|
8060
|
+
// Property derivation aliases
|
|
8061
|
+
"property-derive": "property-derivation",
|
|
8062
|
+
propertyderivation: "property-derivation",
|
|
8063
|
+
"dynamic-properties": "property-derivation",
|
|
8064
|
+
"derived-properties": "property-derivation",
|
|
8065
|
+
"dynamic-options": "property-derivation",
|
|
8066
|
+
"dynamic-label": "property-derivation",
|
|
8067
|
+
mindate: "property-derivation",
|
|
8068
|
+
maxdate: "property-derivation",
|
|
7151
8069
|
// Validation aliases
|
|
7152
8070
|
validators: "validation",
|
|
7153
8071
|
required: "validation",
|
|
@@ -7251,6 +8169,7 @@ function getTopicList() {
|
|
|
7251
8169
|
"validation-messages",
|
|
7252
8170
|
"conditional",
|
|
7253
8171
|
"derivation",
|
|
8172
|
+
"property-derivation",
|
|
7254
8173
|
"options-format",
|
|
7255
8174
|
"expression-variables",
|
|
7256
8175
|
"async-validators",
|
|
@@ -7357,14 +8276,14 @@ Field types: input, select, slider, radio, checkbox, textarea, datepicker, toggl
|
|
|
7357
8276
|
|
|
7358
8277
|
Containers: group, row, array, page
|
|
7359
8278
|
|
|
7360
|
-
Concepts: validation, conditional, derivation, options-format, expression-variables, async-validators
|
|
8279
|
+
Concepts: validation, conditional, derivation, property-derivation, options-format, expression-variables, async-validators
|
|
7361
8280
|
|
|
7362
8281
|
Patterns: field-placement, logic-matrix, context-api, containers, multi-page-gotchas
|
|
7363
8282
|
|
|
7364
8283
|
Use topic="list" to see all available topics.`,
|
|
7365
8284
|
{
|
|
7366
8285
|
topic: z54.string().describe(
|
|
7367
|
-
'Topic to look up: field types (input, select, hidden, group, row, array, page), concepts (validation, conditional, derivation, options-format), patterns (golden-path, pitfalls, multi-page-gotchas), or "list" to see all topics'
|
|
8286
|
+
'Topic to look up: field types (input, select, hidden, group, row, array, page), concepts (validation, conditional, derivation, property-derivation, options-format), patterns (golden-path, pitfalls, multi-page-gotchas), or "list" to see all topics'
|
|
7368
8287
|
),
|
|
7369
8288
|
depth: z54.enum(["brief", "full", "schema"]).default("full").describe("brief=quick syntax (~20 lines), full=complete docs with examples, schema=include JSON schema"),
|
|
7370
8289
|
uiIntegration: z54.enum(UI_INTEGRATIONS2).optional().describe("Filter UI-specific info (only with depth=schema). material, bootstrap, primeng, ionic")
|
|
@@ -7674,7 +8593,7 @@ Show/hide pages based on conditions:
|
|
|
7674
8593
|
\`\`\``
|
|
7675
8594
|
},
|
|
7676
8595
|
"minimal-array": {
|
|
7677
|
-
description: "Dynamic array with
|
|
8596
|
+
description: "Dynamic array with simplified API (~15 lines)",
|
|
7678
8597
|
minimal: `import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
7679
8598
|
|
|
7680
8599
|
const formConfig = {
|
|
@@ -7682,22 +8601,16 @@ const formConfig = {
|
|
|
7682
8601
|
{
|
|
7683
8602
|
key: 'contacts',
|
|
7684
8603
|
type: 'array',
|
|
7685
|
-
|
|
7686
|
-
{
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
7690
|
-
{ key: 'name', type: 'input', label: 'Name', required: true },
|
|
7691
|
-
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
7692
|
-
]
|
|
7693
|
-
}
|
|
7694
|
-
]
|
|
8604
|
+
template: [
|
|
8605
|
+
{ key: 'name', type: 'input', label: 'Name', required: true },
|
|
8606
|
+
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
8607
|
+
],
|
|
8608
|
+
value: [{ name: 'Jane', phone: '555-1234' }]
|
|
7695
8609
|
},
|
|
7696
|
-
{ key: 'addContact', type: 'addArrayItem', label: 'Add Contact', arrayKey: 'contacts' },
|
|
7697
8610
|
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
7698
8611
|
]
|
|
7699
8612
|
} as const satisfies FormConfig;`,
|
|
7700
|
-
brief: `// Dynamic array form
|
|
8613
|
+
brief: `// Dynamic array form using simplified API
|
|
7701
8614
|
import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
7702
8615
|
|
|
7703
8616
|
const formConfig = {
|
|
@@ -7705,67 +8618,78 @@ const formConfig = {
|
|
|
7705
8618
|
{
|
|
7706
8619
|
key: 'contacts',
|
|
7707
8620
|
type: 'array',
|
|
7708
|
-
//
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
7716
|
-
]
|
|
7717
|
-
}
|
|
7718
|
-
]
|
|
8621
|
+
// Simplified API: template + value
|
|
8622
|
+
// Add/remove buttons are auto-generated!
|
|
8623
|
+
template: [
|
|
8624
|
+
{ key: 'name', type: 'input', label: 'Name', required: true },
|
|
8625
|
+
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
8626
|
+
],
|
|
8627
|
+
value: [{ name: 'Jane', phone: '555-1234' }]
|
|
7719
8628
|
},
|
|
7720
|
-
// \u26A0\uFE0F arrayKey at FIELD level, NOT in props!
|
|
7721
|
-
{ key: 'addContact', type: 'addArrayItem', label: 'Add Contact', arrayKey: 'contacts' },
|
|
7722
8629
|
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
7723
8630
|
]
|
|
7724
8631
|
} as const satisfies FormConfig;
|
|
7725
8632
|
|
|
7726
|
-
// Output: { contacts: [{ name: '
|
|
8633
|
+
// Output: { contacts: [{ name: 'Jane', phone: '555-1234' }, ...] }
|
|
8634
|
+
|
|
8635
|
+
// For primitive arrays:
|
|
8636
|
+
// { key: 'tags', type: 'array', template: { key: 'value', type: 'input', label: 'Tag' }, value: ['a', 'b'] }
|
|
8637
|
+
// Output: { tags: ['a', 'b'] }`,
|
|
7727
8638
|
full: `# Minimal Array Form
|
|
7728
8639
|
|
|
7729
|
-
|
|
8640
|
+
Dynamic lists with the simplified array API. Add/remove buttons are auto-generated.
|
|
8641
|
+
|
|
8642
|
+
## Simplified API (Recommended)
|
|
7730
8643
|
|
|
7731
8644
|
\`\`\`typescript
|
|
7732
8645
|
import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
7733
8646
|
|
|
7734
8647
|
const formConfig = {
|
|
7735
8648
|
fields: [
|
|
7736
|
-
// ========== ARRAY
|
|
8649
|
+
// ========== OBJECT ARRAY ==========
|
|
7737
8650
|
{
|
|
7738
8651
|
key: 'contacts',
|
|
7739
8652
|
type: 'array',
|
|
7740
|
-
//
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
{
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
// { key: 'remove', type: 'removeArrayItem', label: 'Remove' }
|
|
7751
|
-
]
|
|
7752
|
-
}
|
|
7753
|
-
]
|
|
8653
|
+
// template: array of fields = object items
|
|
8654
|
+
template: [
|
|
8655
|
+
{ key: 'name', type: 'input', label: 'Name', required: true },
|
|
8656
|
+
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
8657
|
+
],
|
|
8658
|
+
// Initial values (each element creates one item)
|
|
8659
|
+
value: [{ name: 'Jane', phone: '555-1234' }],
|
|
8660
|
+
// Optional: customize buttons
|
|
8661
|
+
addButton: { label: 'Add Contact' },
|
|
8662
|
+
// removeButton: false // disable remove buttons
|
|
7754
8663
|
},
|
|
7755
|
-
// ========== ADD BUTTON (outside array) ==========
|
|
7756
|
-
// \u26A0\uFE0F arrayKey goes at FIELD level, NOT in props!
|
|
7757
|
-
{ key: 'addContact', type: 'addArrayItem', label: 'Add Contact', arrayKey: 'contacts' },
|
|
7758
8664
|
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
7759
8665
|
]
|
|
7760
8666
|
} as const satisfies FormConfig;
|
|
7761
8667
|
\`\`\`
|
|
7762
8668
|
|
|
8669
|
+
## Primitive Array
|
|
8670
|
+
|
|
8671
|
+
\`\`\`typescript
|
|
8672
|
+
const tagForm = {
|
|
8673
|
+
fields: [
|
|
8674
|
+
{
|
|
8675
|
+
key: 'tags',
|
|
8676
|
+
type: 'array',
|
|
8677
|
+
// template: single field = primitive items
|
|
8678
|
+
template: { key: 'value', type: 'input', label: 'Tag' },
|
|
8679
|
+
value: ['angular', 'typescript']
|
|
8680
|
+
},
|
|
8681
|
+
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
8682
|
+
]
|
|
8683
|
+
} as const satisfies FormConfig;
|
|
8684
|
+
// Output: { tags: ['angular', 'typescript'] }
|
|
8685
|
+
\`\`\`
|
|
8686
|
+
|
|
7763
8687
|
## Output Shape
|
|
7764
8688
|
|
|
7765
8689
|
\`\`\`typescript
|
|
7766
8690
|
{
|
|
7767
8691
|
contacts: [
|
|
7768
|
-
{ name: '
|
|
8692
|
+
{ name: 'Jane', phone: '555-1234' },
|
|
7769
8693
|
{ name: 'Bob', phone: '555-5678' }
|
|
7770
8694
|
]
|
|
7771
8695
|
}
|
|
@@ -7773,39 +8697,96 @@ const formConfig = {
|
|
|
7773
8697
|
|
|
7774
8698
|
## Key Rules
|
|
7775
8699
|
|
|
7776
|
-
-
|
|
7777
|
-
- \`
|
|
7778
|
-
-
|
|
8700
|
+
- **Simplified API:** Use \`template\` + \`value\` (add/remove buttons are auto-generated)
|
|
8701
|
+
- **Full API:** Use \`fields\` (you manage add/remove buttons manually)
|
|
8702
|
+
- \`fields\` and \`template\` are **mutually exclusive**
|
|
8703
|
+
- Arrays have NO label property
|
|
8704
|
+
- Only \`hidden\` logic type is supported
|
|
8705
|
+
|
|
8706
|
+
## Full API (Advanced)
|
|
8707
|
+
|
|
8708
|
+
For full control, use the \`fields\`-based API with explicit add/remove buttons:
|
|
8709
|
+
|
|
8710
|
+
\`\`\`typescript
|
|
8711
|
+
{
|
|
8712
|
+
key: 'contacts',
|
|
8713
|
+
type: 'array',
|
|
8714
|
+
minLength: 1, // Optional: minimum number of items
|
|
8715
|
+
maxLength: 5, // Optional: maximum number of items
|
|
8716
|
+
fields: [
|
|
8717
|
+
[
|
|
8718
|
+
{ key: 'name', type: 'input', label: 'Name', value: 'Alice' },
|
|
8719
|
+
{ key: 'phone', type: 'input', label: 'Phone', value: '555' }
|
|
8720
|
+
]
|
|
8721
|
+
]
|
|
8722
|
+
}
|
|
8723
|
+
// + separate addArrayItem button with arrayKey
|
|
8724
|
+
\`\`\``,
|
|
7779
8725
|
explained: `# Array Fields - Complete Explanation
|
|
7780
8726
|
|
|
7781
|
-
Arrays create dynamic, repeatable lists of items.
|
|
8727
|
+
Arrays create dynamic, repeatable lists of items. Two APIs are available.
|
|
7782
8728
|
|
|
7783
|
-
##
|
|
8729
|
+
## Simplified API (Recommended)
|
|
8730
|
+
|
|
8731
|
+
The simplified API uses \`template\` + \`value\` and auto-generates add/remove buttons.
|
|
8732
|
+
|
|
8733
|
+
### Primitive Array
|
|
8734
|
+
\`\`\`typescript
|
|
8735
|
+
{
|
|
8736
|
+
key: 'tags',
|
|
8737
|
+
type: 'array',
|
|
8738
|
+
template: { key: 'value', type: 'input', label: 'Tag' }, // Single field = primitive
|
|
8739
|
+
value: ['angular', 'typescript']
|
|
8740
|
+
}
|
|
8741
|
+
// Output: { tags: ['angular', 'typescript'] }
|
|
8742
|
+
\`\`\`
|
|
8743
|
+
|
|
8744
|
+
### Object Array
|
|
8745
|
+
\`\`\`typescript
|
|
8746
|
+
{
|
|
8747
|
+
key: 'contacts',
|
|
8748
|
+
type: 'array',
|
|
8749
|
+
template: [ // Array = object items
|
|
8750
|
+
{ key: 'name', type: 'input', label: 'Name' },
|
|
8751
|
+
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
8752
|
+
],
|
|
8753
|
+
value: [{ name: 'Jane', phone: '555' }]
|
|
8754
|
+
}
|
|
8755
|
+
// Output: { contacts: [{ name: 'Jane', phone: '555' }] }
|
|
8756
|
+
\`\`\`
|
|
8757
|
+
|
|
8758
|
+
### Button Customization
|
|
8759
|
+
\`\`\`typescript
|
|
8760
|
+
{
|
|
8761
|
+
key: 'items',
|
|
8762
|
+
type: 'array',
|
|
8763
|
+
template: { key: 'value', type: 'input', label: 'Item' },
|
|
8764
|
+
addButton: { label: 'Add Item', props: { color: 'primary' } },
|
|
8765
|
+
removeButton: false // Disable remove buttons
|
|
8766
|
+
}
|
|
8767
|
+
\`\`\`
|
|
8768
|
+
|
|
8769
|
+
## Full API (Advanced)
|
|
8770
|
+
|
|
8771
|
+
For full control, use \`fields\` with explicit item definitions and manual add/remove buttons.
|
|
7784
8772
|
|
|
7785
8773
|
\`\`\`typescript
|
|
7786
8774
|
{
|
|
7787
8775
|
key: 'contacts',
|
|
7788
8776
|
type: 'array',
|
|
7789
|
-
// \u26A0\uFE0F Arrays DO NOT support:
|
|
7790
|
-
// - label (no visual label)
|
|
7791
|
-
// - logic (apply logic to children instead)
|
|
7792
|
-
// - minItems/maxItems (not implemented)
|
|
7793
|
-
// - template (use 'fields' instead)
|
|
7794
8777
|
fields: [
|
|
7795
|
-
//
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
]
|
|
7803
|
-
}
|
|
8778
|
+
// Each element is one array item:
|
|
8779
|
+
// - Single FieldDef = primitive item
|
|
8780
|
+
// - Array of FieldDefs = object item
|
|
8781
|
+
[
|
|
8782
|
+
{ key: 'name', type: 'input', label: 'Name', value: 'Alice' },
|
|
8783
|
+
{ key: 'phone', type: 'input', label: 'Phone', value: '555' }
|
|
8784
|
+
]
|
|
7804
8785
|
]
|
|
7805
8786
|
}
|
|
7806
8787
|
\`\`\`
|
|
7807
8788
|
|
|
7808
|
-
|
|
8789
|
+
### Manual Add/Remove Buttons (Full API)
|
|
7809
8790
|
|
|
7810
8791
|
\`\`\`typescript
|
|
7811
8792
|
// ADD button - place OUTSIDE the array
|
|
@@ -7820,43 +8801,148 @@ Arrays create dynamic, repeatable lists of items.
|
|
|
7820
8801
|
{
|
|
7821
8802
|
key: 'contacts',
|
|
7822
8803
|
type: 'array',
|
|
7823
|
-
fields: [
|
|
7824
|
-
|
|
7825
|
-
type: 'group',
|
|
7826
|
-
fields: [
|
|
8804
|
+
fields: [
|
|
8805
|
+
[
|
|
7827
8806
|
{ key: 'name', type: 'input', label: 'Name' },
|
|
7828
|
-
{ key: 'remove', type: 'removeArrayItem', label: 'Remove' }
|
|
8807
|
+
{ key: 'remove', type: 'removeArrayItem', label: 'Remove' }
|
|
7829
8808
|
]
|
|
7830
|
-
|
|
8809
|
+
]
|
|
7831
8810
|
}
|
|
7832
8811
|
\`\`\`
|
|
7833
8812
|
|
|
7834
|
-
## Accessing Array Values in Expressions
|
|
8813
|
+
## Accessing Array Values in Expressions
|
|
8814
|
+
|
|
8815
|
+
\`\`\`typescript
|
|
8816
|
+
// Count items
|
|
8817
|
+
expression: '(formValue.contacts?.length || 0)'
|
|
8818
|
+
|
|
8819
|
+
// Access specific index
|
|
8820
|
+
expression: 'formValue.contacts?.[0]?.name'
|
|
8821
|
+
|
|
8822
|
+
// Sum values
|
|
8823
|
+
expression: '(formValue.items || []).reduce((sum, item) => sum + (item.price || 0), 0)'
|
|
8824
|
+
\`\`\`
|
|
8825
|
+
|
|
8826
|
+
## Common Mistakes
|
|
8827
|
+
|
|
8828
|
+
\u274C Mixing \`fields\` and \`template\`:
|
|
8829
|
+
\`\`\`typescript
|
|
8830
|
+
{ type: 'array', fields: [...], template: {...} } // WRONG - mutually exclusive
|
|
8831
|
+
\`\`\`
|
|
8832
|
+
|
|
8833
|
+
\u274C Wrong value shape for object templates:
|
|
8834
|
+
\`\`\`typescript
|
|
8835
|
+
{ template: [{ key: 'name', type: 'input' }], value: ['Jane'] } // WRONG
|
|
8836
|
+
{ template: [{ key: 'name', type: 'input' }], value: [{ name: 'Jane' }] } // CORRECT
|
|
8837
|
+
\`\`\`
|
|
8838
|
+
|
|
8839
|
+
\u274C Putting \`arrayKey\` in props (full API):
|
|
8840
|
+
\`\`\`typescript
|
|
8841
|
+
{ type: 'addArrayItem', props: { arrayKey: 'x' } } // WRONG
|
|
8842
|
+
{ type: 'addArrayItem', arrayKey: 'x' } // CORRECT
|
|
8843
|
+
\`\`\``
|
|
8844
|
+
},
|
|
8845
|
+
"minimal-simplified-array": {
|
|
8846
|
+
description: "Simplified array API with template + value (~15 lines)",
|
|
8847
|
+
minimal: `import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
8848
|
+
|
|
8849
|
+
const formConfig = {
|
|
8850
|
+
fields: [
|
|
8851
|
+
{
|
|
8852
|
+
key: 'tags',
|
|
8853
|
+
type: 'array',
|
|
8854
|
+
template: { key: 'value', type: 'input', label: 'Tag' },
|
|
8855
|
+
value: ['angular', 'typescript']
|
|
8856
|
+
},
|
|
8857
|
+
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
8858
|
+
]
|
|
8859
|
+
} as const satisfies FormConfig;`,
|
|
8860
|
+
brief: `// Simplified array - primitive values
|
|
8861
|
+
import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
8862
|
+
|
|
8863
|
+
const formConfig = {
|
|
8864
|
+
fields: [
|
|
8865
|
+
// Primitive array with auto add/remove buttons
|
|
8866
|
+
{
|
|
8867
|
+
key: 'tags',
|
|
8868
|
+
type: 'array',
|
|
8869
|
+
template: { key: 'value', type: 'input', label: 'Tag' }, // Single field = primitive
|
|
8870
|
+
value: ['angular', 'typescript']
|
|
8871
|
+
},
|
|
8872
|
+
// Object array
|
|
8873
|
+
{
|
|
8874
|
+
key: 'contacts',
|
|
8875
|
+
type: 'array',
|
|
8876
|
+
template: [ // Array of fields = object items
|
|
8877
|
+
{ key: 'name', type: 'input', label: 'Name', required: true },
|
|
8878
|
+
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
8879
|
+
],
|
|
8880
|
+
value: [{ name: 'Jane', phone: '555' }],
|
|
8881
|
+
addButton: { label: 'Add Contact' },
|
|
8882
|
+
removeButton: { label: 'Delete', props: { color: 'warn' } }
|
|
8883
|
+
},
|
|
8884
|
+
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
8885
|
+
]
|
|
8886
|
+
} as const satisfies FormConfig;`,
|
|
8887
|
+
full: `# Simplified Array API
|
|
8888
|
+
|
|
8889
|
+
The concise way to define dynamic arrays. Auto-generates add/remove buttons.
|
|
7835
8890
|
|
|
7836
|
-
|
|
7837
|
-
// Count items
|
|
7838
|
-
expression: '(formValue.contacts?.length || 0)'
|
|
8891
|
+
## Primitive Array
|
|
7839
8892
|
|
|
7840
|
-
|
|
7841
|
-
|
|
8893
|
+
\`\`\`typescript
|
|
8894
|
+
import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
7842
8895
|
|
|
7843
|
-
|
|
7844
|
-
|
|
8896
|
+
const formConfig = {
|
|
8897
|
+
fields: [
|
|
8898
|
+
{
|
|
8899
|
+
key: 'tags',
|
|
8900
|
+
type: 'array',
|
|
8901
|
+
template: { key: 'value', type: 'input', label: 'Tag' }, // Single field = primitive items
|
|
8902
|
+
value: ['angular', 'typescript'] // Initial values
|
|
8903
|
+
// Add/remove buttons are auto-generated!
|
|
8904
|
+
},
|
|
8905
|
+
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
8906
|
+
]
|
|
8907
|
+
} as const satisfies FormConfig;
|
|
8908
|
+
// Output: { tags: ['angular', 'typescript'] }
|
|
7845
8909
|
\`\`\`
|
|
7846
8910
|
|
|
7847
|
-
##
|
|
8911
|
+
## Object Array
|
|
7848
8912
|
|
|
7849
|
-
\u274C Using \`template\` instead of \`fields\`:
|
|
7850
8913
|
\`\`\`typescript
|
|
7851
|
-
|
|
7852
|
-
|
|
8914
|
+
const contactForm = {
|
|
8915
|
+
fields: [
|
|
8916
|
+
{
|
|
8917
|
+
key: 'contacts',
|
|
8918
|
+
type: 'array',
|
|
8919
|
+
template: [ // Array = object items
|
|
8920
|
+
{ key: 'name', type: 'input', label: 'Name', required: true },
|
|
8921
|
+
{ key: 'phone', type: 'input', label: 'Phone' }
|
|
8922
|
+
],
|
|
8923
|
+
value: [
|
|
8924
|
+
{ name: 'Jane', phone: '555-1234' },
|
|
8925
|
+
{ name: 'John', phone: '555-5678' }
|
|
8926
|
+
],
|
|
8927
|
+
addButton: { label: 'Add Contact', props: { color: 'primary' } },
|
|
8928
|
+
removeButton: { label: 'Delete' }
|
|
8929
|
+
},
|
|
8930
|
+
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
8931
|
+
]
|
|
8932
|
+
} as const satisfies FormConfig;
|
|
8933
|
+
// Output: { contacts: [{ name: 'Jane', phone: '555-1234' }, ...] }
|
|
7853
8934
|
\`\`\`
|
|
7854
8935
|
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
8936
|
+
## Key Points
|
|
8937
|
+
|
|
8938
|
+
- **Single field template** = primitive array (each item is a value)
|
|
8939
|
+
- **Array of fields template** = object array (each item is an object)
|
|
8940
|
+
- **Add/remove buttons** are auto-generated by default
|
|
8941
|
+
- Use \`addButton: false\` or \`removeButton: false\` to disable
|
|
8942
|
+
- \`template\` and \`fields\` are **mutually exclusive**`,
|
|
8943
|
+
explained: `# Simplified Array API - Complete Explanation
|
|
8944
|
+
|
|
8945
|
+
See the \`simplified-array\` lookup topic for full documentation.`
|
|
7860
8946
|
},
|
|
7861
8947
|
"minimal-conditional": {
|
|
7862
8948
|
description: "Show/hide field based on condition (~25 lines)",
|
|
@@ -8244,7 +9330,7 @@ provideDynamicForm({
|
|
|
8244
9330
|
})
|
|
8245
9331
|
|
|
8246
9332
|
// 3. Use
|
|
8247
|
-
validators: [{ type: '
|
|
9333
|
+
validators: [{ type: 'async', functionName: 'checkEmailAvailable' }]
|
|
8248
9334
|
\`\`\`
|
|
8249
9335
|
|
|
8250
9336
|
## Error Messages
|
|
@@ -8399,6 +9485,429 @@ These will cause validation errors:
|
|
|
8399
9485
|
{ key: 'roleId', type: 'hidden', value: 42 } // number
|
|
8400
9486
|
{ key: 'isActive', type: 'hidden', value: true } // boolean
|
|
8401
9487
|
{ key: 'tagIds', type: 'hidden', value: [1, 2, 3] } // array
|
|
9488
|
+
\`\`\``
|
|
9489
|
+
},
|
|
9490
|
+
"property-derivation": {
|
|
9491
|
+
description: "Derive field properties (minDate, options, label) from form values",
|
|
9492
|
+
minimal: `import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
9493
|
+
|
|
9494
|
+
const formConfig = {
|
|
9495
|
+
fields: [
|
|
9496
|
+
{ key: 'startDate', type: 'datepicker', label: 'Start Date' },
|
|
9497
|
+
{
|
|
9498
|
+
key: 'endDate',
|
|
9499
|
+
type: 'datepicker',
|
|
9500
|
+
label: 'End Date',
|
|
9501
|
+
logic: [{
|
|
9502
|
+
type: 'derivation',
|
|
9503
|
+
targetProperty: 'minDate',
|
|
9504
|
+
expression: 'formValue.startDate',
|
|
9505
|
+
}]
|
|
9506
|
+
},
|
|
9507
|
+
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
9508
|
+
]
|
|
9509
|
+
} as const satisfies FormConfig;`,
|
|
9510
|
+
brief: `// Derive component properties from form values
|
|
9511
|
+
import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
9512
|
+
|
|
9513
|
+
const formConfig = {
|
|
9514
|
+
fields: [
|
|
9515
|
+
{ key: 'startDate', type: 'datepicker', label: 'Start Date' },
|
|
9516
|
+
{
|
|
9517
|
+
key: 'endDate',
|
|
9518
|
+
type: 'datepicker',
|
|
9519
|
+
label: 'End Date',
|
|
9520
|
+
// type: 'derivation' + targetProperty = PROPERTY derivation
|
|
9521
|
+
// Without targetProperty, it would be a VALUE derivation
|
|
9522
|
+
logic: [{
|
|
9523
|
+
type: 'derivation',
|
|
9524
|
+
targetProperty: 'minDate',
|
|
9525
|
+
expression: 'formValue.startDate',
|
|
9526
|
+
}]
|
|
9527
|
+
},
|
|
9528
|
+
{
|
|
9529
|
+
key: 'country',
|
|
9530
|
+
type: 'select',
|
|
9531
|
+
label: 'Country',
|
|
9532
|
+
options: [{ label: 'USA', value: 'US' }, { label: 'Germany', value: 'DE' }]
|
|
9533
|
+
},
|
|
9534
|
+
{
|
|
9535
|
+
key: 'city',
|
|
9536
|
+
type: 'select',
|
|
9537
|
+
label: 'City',
|
|
9538
|
+
options: [],
|
|
9539
|
+
// Dynamic options from custom function
|
|
9540
|
+
logic: [{
|
|
9541
|
+
type: 'derivation',
|
|
9542
|
+
targetProperty: 'options',
|
|
9543
|
+
functionName: 'getCitiesForCountry',
|
|
9544
|
+
dependsOn: ['country'],
|
|
9545
|
+
}]
|
|
9546
|
+
},
|
|
9547
|
+
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
9548
|
+
]
|
|
9549
|
+
} as const satisfies FormConfig;
|
|
9550
|
+
|
|
9551
|
+
// Key points:
|
|
9552
|
+
// - type: 'derivation' sets component PROPERTIES (not form value)
|
|
9553
|
+
// - targetProperty: which input property to set (e.g., 'minDate', 'options')
|
|
9554
|
+
// - Supports: expression, value, functionName (mutually exclusive)
|
|
9555
|
+
// - Register functions in customFnConfig.derivations`,
|
|
9556
|
+
full: `# Property Derivation (Dynamic Field Properties)
|
|
9557
|
+
|
|
9558
|
+
Derive field component properties reactively based on form values.
|
|
9559
|
+
|
|
9560
|
+
\`\`\`typescript
|
|
9561
|
+
import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
9562
|
+
|
|
9563
|
+
const formConfig = {
|
|
9564
|
+
fields: [
|
|
9565
|
+
// ========== DATE RANGE: minDate from startDate ==========
|
|
9566
|
+
{ key: 'startDate', type: 'datepicker', label: 'Start Date' },
|
|
9567
|
+
{
|
|
9568
|
+
key: 'endDate',
|
|
9569
|
+
type: 'datepicker',
|
|
9570
|
+
label: 'End Date',
|
|
9571
|
+
logic: [{
|
|
9572
|
+
type: 'derivation',
|
|
9573
|
+
targetProperty: 'minDate', // Set minDate property
|
|
9574
|
+
expression: 'formValue.startDate', // From startDate's value
|
|
9575
|
+
}]
|
|
9576
|
+
},
|
|
9577
|
+
|
|
9578
|
+
// ========== DYNAMIC OPTIONS: cities from country ==========
|
|
9579
|
+
{
|
|
9580
|
+
key: 'country',
|
|
9581
|
+
type: 'select',
|
|
9582
|
+
label: 'Country',
|
|
9583
|
+
options: [
|
|
9584
|
+
{ label: 'USA', value: 'US' },
|
|
9585
|
+
{ label: 'Germany', value: 'DE' }
|
|
9586
|
+
]
|
|
9587
|
+
},
|
|
9588
|
+
{
|
|
9589
|
+
key: 'city',
|
|
9590
|
+
type: 'select',
|
|
9591
|
+
label: 'City',
|
|
9592
|
+
options: [], // Initial empty, filled by property derivation
|
|
9593
|
+
logic: [{
|
|
9594
|
+
type: 'derivation',
|
|
9595
|
+
targetProperty: 'options',
|
|
9596
|
+
functionName: 'getCitiesForCountry',
|
|
9597
|
+
dependsOn: ['country'],
|
|
9598
|
+
}]
|
|
9599
|
+
},
|
|
9600
|
+
|
|
9601
|
+
// ========== CONDITIONAL LABEL ==========
|
|
9602
|
+
{
|
|
9603
|
+
key: 'phone',
|
|
9604
|
+
type: 'input',
|
|
9605
|
+
label: 'Phone',
|
|
9606
|
+
logic: [{
|
|
9607
|
+
type: 'derivation',
|
|
9608
|
+
targetProperty: 'label',
|
|
9609
|
+
value: 'Mobile Phone',
|
|
9610
|
+
condition: {
|
|
9611
|
+
type: 'fieldValue',
|
|
9612
|
+
fieldPath: 'contactType',
|
|
9613
|
+
operator: 'equals',
|
|
9614
|
+
value: 'mobile'
|
|
9615
|
+
},
|
|
9616
|
+
}]
|
|
9617
|
+
},
|
|
9618
|
+
|
|
9619
|
+
// ========== NESTED PROPERTY ==========
|
|
9620
|
+
{
|
|
9621
|
+
key: 'notes',
|
|
9622
|
+
type: 'textarea',
|
|
9623
|
+
label: 'Notes',
|
|
9624
|
+
logic: [{
|
|
9625
|
+
type: 'derivation',
|
|
9626
|
+
targetProperty: 'props.appearance', // Dot-notation for nested
|
|
9627
|
+
expression: 'formValue.isPremium ? "fill" : "outline"',
|
|
9628
|
+
}]
|
|
9629
|
+
},
|
|
9630
|
+
|
|
9631
|
+
{ key: 'submit', type: 'submit', label: 'Submit' }
|
|
9632
|
+
]
|
|
9633
|
+
} as const satisfies FormConfig;
|
|
9634
|
+
\`\`\`
|
|
9635
|
+
|
|
9636
|
+
## Custom Function Registration
|
|
9637
|
+
|
|
9638
|
+
\`\`\`typescript
|
|
9639
|
+
// customFnConfig is a property of FormConfig, not a provider option
|
|
9640
|
+
const formConfig = {
|
|
9641
|
+
customFnConfig: {
|
|
9642
|
+
derivations: {
|
|
9643
|
+
getCitiesForCountry: (ctx) => {
|
|
9644
|
+
const cities: Record<string, { label: string; value: string }[]> = {
|
|
9645
|
+
'US': [{ label: 'New York', value: 'nyc' }, { label: 'LA', value: 'la' }],
|
|
9646
|
+
'DE': [{ label: 'Berlin', value: 'berlin' }, { label: 'Munich', value: 'munich' }],
|
|
9647
|
+
};
|
|
9648
|
+
return cities[ctx.formValue.country as string] ?? [];
|
|
9649
|
+
},
|
|
9650
|
+
},
|
|
9651
|
+
},
|
|
9652
|
+
fields: [
|
|
9653
|
+
// ... fields with derivation + targetProperty logic referencing 'getCitiesForCountry'
|
|
9654
|
+
],
|
|
9655
|
+
} as const satisfies FormConfig;
|
|
9656
|
+
\`\`\`
|
|
9657
|
+
|
|
9658
|
+
## Key Differences from Value Derivation
|
|
9659
|
+
|
|
9660
|
+
| Feature | Value Derivation | Property Derivation |
|
|
9661
|
+
|---------|----------------------|-------------------------------|
|
|
9662
|
+
| Type | \`type: 'derivation'\` | \`type: 'derivation'\` + \`targetProperty\` |
|
|
9663
|
+
| Sets | Field's form value | Component property |
|
|
9664
|
+
| Target | Implicit (self) | \`targetProperty: 'minDate'\` |
|
|
9665
|
+
| Shorthand | \`derivation: 'expr'\` | None |
|
|
9666
|
+
| Functions | \`customFnConfig.derivations\` | \`customFnConfig.derivations\` |
|
|
9667
|
+
|
|
9668
|
+
The presence of \`targetProperty\` distinguishes a property derivation from a value derivation.`,
|
|
9669
|
+
explained: `# Property Derivation - Complete Explanation
|
|
9670
|
+
|
|
9671
|
+
## What is Property Derivation?
|
|
9672
|
+
|
|
9673
|
+
Property derivation allows you to **reactively update component input properties** based on form state. Both value and property derivations use \`type: 'derivation'\`. The distinction is the \`targetProperty\` field: when present, the derivation targets a component property; when absent, it targets the field's form value.
|
|
9674
|
+
|
|
9675
|
+
## When to Use Property Derivation
|
|
9676
|
+
|
|
9677
|
+
Use property derivation when:
|
|
9678
|
+
- **Date constraints**: endDate's minDate should follow startDate
|
|
9679
|
+
- **Dynamic options**: City options depend on selected country
|
|
9680
|
+
- **Dynamic labels**: Field label changes based on another field
|
|
9681
|
+
- **Dynamic appearance**: Input style changes based on form state
|
|
9682
|
+
- **Conditional placeholders**: Placeholder text depends on context
|
|
9683
|
+
|
|
9684
|
+
## Syntax
|
|
9685
|
+
|
|
9686
|
+
\`\`\`typescript
|
|
9687
|
+
{
|
|
9688
|
+
key: 'endDate',
|
|
9689
|
+
type: 'datepicker',
|
|
9690
|
+
label: 'End Date',
|
|
9691
|
+
logic: [{
|
|
9692
|
+
type: 'derivation', // Same type as value derivation
|
|
9693
|
+
targetProperty: 'minDate', // targetProperty makes it a PROPERTY derivation
|
|
9694
|
+
expression: 'formValue.startDate', // How to compute it
|
|
9695
|
+
}]
|
|
9696
|
+
}
|
|
9697
|
+
\`\`\`
|
|
9698
|
+
|
|
9699
|
+
## Three Ways to Compute Values
|
|
9700
|
+
|
|
9701
|
+
### 1. Expression (inline JavaScript)
|
|
9702
|
+
\`\`\`typescript
|
|
9703
|
+
logic: [{
|
|
9704
|
+
type: 'derivation',
|
|
9705
|
+
targetProperty: 'minDate',
|
|
9706
|
+
expression: 'formValue.startDate', // Access form values
|
|
9707
|
+
}]
|
|
9708
|
+
\`\`\`
|
|
9709
|
+
|
|
9710
|
+
### 2. Static Value (with condition)
|
|
9711
|
+
\`\`\`typescript
|
|
9712
|
+
logic: [{
|
|
9713
|
+
type: 'derivation',
|
|
9714
|
+
targetProperty: 'label',
|
|
9715
|
+
value: 'Business Email', // Fixed value
|
|
9716
|
+
condition: { type: 'fieldValue', fieldPath: 'type', operator: 'equals', value: 'business' },
|
|
9717
|
+
}]
|
|
9718
|
+
\`\`\`
|
|
9719
|
+
|
|
9720
|
+
### 3. Custom Function (for complex logic)
|
|
9721
|
+
\`\`\`typescript
|
|
9722
|
+
// In form config
|
|
9723
|
+
customFnConfig: {
|
|
9724
|
+
derivations: {
|
|
9725
|
+
getCitiesForCountry: (ctx) => {
|
|
9726
|
+
// ctx.formValue contains all form values
|
|
9727
|
+
// ctx.externalData contains external signals (unwrapped)
|
|
9728
|
+
return lookupCities(ctx.formValue.country);
|
|
9729
|
+
},
|
|
9730
|
+
},
|
|
9731
|
+
}
|
|
9732
|
+
|
|
9733
|
+
// In field
|
|
9734
|
+
logic: [{
|
|
9735
|
+
type: 'derivation',
|
|
9736
|
+
targetProperty: 'options',
|
|
9737
|
+
functionName: 'getCitiesForCountry',
|
|
9738
|
+
dependsOn: ['country'], // \u26A0\uFE0F Recommended for custom functions!
|
|
9739
|
+
}]
|
|
9740
|
+
\`\`\`
|
|
9741
|
+
|
|
9742
|
+
## Target Property Formats
|
|
9743
|
+
|
|
9744
|
+
### Simple properties (1 level)
|
|
9745
|
+
\`\`\`typescript
|
|
9746
|
+
targetProperty: 'minDate' // Direct property
|
|
9747
|
+
targetProperty: 'options' // Array of options
|
|
9748
|
+
targetProperty: 'label' // Field label
|
|
9749
|
+
targetProperty: 'placeholder' // Field placeholder
|
|
9750
|
+
targetProperty: 'hint' // Hint text
|
|
9751
|
+
\`\`\`
|
|
9752
|
+
|
|
9753
|
+
### Nested properties (2 levels max)
|
|
9754
|
+
\`\`\`typescript
|
|
9755
|
+
targetProperty: 'props.appearance' // Nested in props
|
|
9756
|
+
targetProperty: 'props.color' // Material color
|
|
9757
|
+
targetProperty: 'meta.autocomplete' // Custom metadata
|
|
9758
|
+
\`\`\`
|
|
9759
|
+
|
|
9760
|
+
### \u274C Not supported (3+ levels)
|
|
9761
|
+
\`\`\`typescript
|
|
9762
|
+
targetProperty: 'props.nested.deep' // \u274C Too deep!
|
|
9763
|
+
\`\`\`
|
|
9764
|
+
|
|
9765
|
+
## Trigger Timing
|
|
9766
|
+
|
|
9767
|
+
\`\`\`typescript
|
|
9768
|
+
// Default: evaluate immediately on form value change
|
|
9769
|
+
logic: [{
|
|
9770
|
+
type: 'derivation',
|
|
9771
|
+
targetProperty: 'options',
|
|
9772
|
+
expression: '...',
|
|
9773
|
+
trigger: 'onChange', // default
|
|
9774
|
+
}]
|
|
9775
|
+
|
|
9776
|
+
// Debounced: wait for typing to stop
|
|
9777
|
+
logic: [{
|
|
9778
|
+
type: 'derivation',
|
|
9779
|
+
targetProperty: 'options',
|
|
9780
|
+
functionName: 'searchProducts',
|
|
9781
|
+
trigger: 'debounced',
|
|
9782
|
+
debounceMs: 300,
|
|
9783
|
+
dependsOn: ['searchQuery'],
|
|
9784
|
+
}]
|
|
9785
|
+
\`\`\`
|
|
9786
|
+
|
|
9787
|
+
## Array Field Support
|
|
9788
|
+
|
|
9789
|
+
Inside arrays, \`formValue\` is scoped to the current array item:
|
|
9790
|
+
|
|
9791
|
+
\`\`\`typescript
|
|
9792
|
+
{
|
|
9793
|
+
key: 'lineItems',
|
|
9794
|
+
type: 'array',
|
|
9795
|
+
fields: [{
|
|
9796
|
+
key: 'item', type: 'group',
|
|
9797
|
+
fields: [
|
|
9798
|
+
{ key: 'startDate', type: 'datepicker', label: 'Start' },
|
|
9799
|
+
{
|
|
9800
|
+
key: 'endDate', type: 'datepicker', label: 'End',
|
|
9801
|
+
logic: [{
|
|
9802
|
+
type: 'derivation',
|
|
9803
|
+
targetProperty: 'minDate',
|
|
9804
|
+
expression: 'formValue.startDate', // Refers to THIS item's startDate
|
|
9805
|
+
}]
|
|
9806
|
+
}
|
|
9807
|
+
]
|
|
9808
|
+
}]
|
|
9809
|
+
}
|
|
9810
|
+
\`\`\`
|
|
9811
|
+
|
|
9812
|
+
## Dependencies
|
|
9813
|
+
|
|
9814
|
+
### Automatic (for expressions)
|
|
9815
|
+
Dependencies are auto-extracted from expressions:
|
|
9816
|
+
\`\`\`typescript
|
|
9817
|
+
expression: 'formValue.startDate' // Automatically depends on 'startDate'
|
|
9818
|
+
\`\`\`
|
|
9819
|
+
|
|
9820
|
+
### Explicit (for custom functions)
|
|
9821
|
+
\`\`\`typescript
|
|
9822
|
+
functionName: 'getCities',
|
|
9823
|
+
dependsOn: ['country'], // Only re-evaluate when 'country' changes
|
|
9824
|
+
\`\`\`
|
|
9825
|
+
|
|
9826
|
+
### Wildcard (default for functions without dependsOn)
|
|
9827
|
+
Functions without \`dependsOn\` re-evaluate on ANY form change. Add \`dependsOn\` for performance.
|
|
9828
|
+
|
|
9829
|
+
## Complete Example
|
|
9830
|
+
|
|
9831
|
+
\`\`\`typescript
|
|
9832
|
+
import { FormConfig } from '@ng-forge/dynamic-forms';
|
|
9833
|
+
|
|
9834
|
+
const travelForm = {
|
|
9835
|
+
customFnConfig: {
|
|
9836
|
+
derivations: {
|
|
9837
|
+
getCitiesForCountry: (ctx) => {
|
|
9838
|
+
const cities: Record<string, { label: string; value: string }[]> = {
|
|
9839
|
+
'US': [{ label: 'New York', value: 'nyc' }, { label: 'LA', value: 'la' }],
|
|
9840
|
+
'DE': [{ label: 'Berlin', value: 'berlin' }],
|
|
9841
|
+
};
|
|
9842
|
+
return cities[ctx.formValue.country as string] ?? [];
|
|
9843
|
+
},
|
|
9844
|
+
},
|
|
9845
|
+
},
|
|
9846
|
+
fields: [
|
|
9847
|
+
{
|
|
9848
|
+
key: 'country',
|
|
9849
|
+
type: 'select',
|
|
9850
|
+
label: 'Country',
|
|
9851
|
+
options: [
|
|
9852
|
+
{ label: 'USA', value: 'US' },
|
|
9853
|
+
{ label: 'Germany', value: 'DE' },
|
|
9854
|
+
],
|
|
9855
|
+
},
|
|
9856
|
+
{
|
|
9857
|
+
key: 'city',
|
|
9858
|
+
type: 'select',
|
|
9859
|
+
label: 'City',
|
|
9860
|
+
options: [],
|
|
9861
|
+
logic: [{
|
|
9862
|
+
type: 'derivation',
|
|
9863
|
+
targetProperty: 'options',
|
|
9864
|
+
functionName: 'getCitiesForCountry',
|
|
9865
|
+
dependsOn: ['country'],
|
|
9866
|
+
}],
|
|
9867
|
+
},
|
|
9868
|
+
{ key: 'startDate', type: 'datepicker', label: 'Travel Start' },
|
|
9869
|
+
{
|
|
9870
|
+
key: 'endDate',
|
|
9871
|
+
type: 'datepicker',
|
|
9872
|
+
label: 'Travel End',
|
|
9873
|
+
logic: [{
|
|
9874
|
+
type: 'derivation',
|
|
9875
|
+
targetProperty: 'minDate',
|
|
9876
|
+
expression: 'formValue.startDate',
|
|
9877
|
+
}],
|
|
9878
|
+
},
|
|
9879
|
+
{ key: 'submit', type: 'submit', label: 'Book Trip' },
|
|
9880
|
+
],
|
|
9881
|
+
} as const satisfies FormConfig;
|
|
9882
|
+
\`\`\`
|
|
9883
|
+
|
|
9884
|
+
## Common Mistakes
|
|
9885
|
+
|
|
9886
|
+
\u274C Confusing with value derivation:
|
|
9887
|
+
\`\`\`typescript
|
|
9888
|
+
// WRONG - 'derivation' changes the form value, not a property
|
|
9889
|
+
logic: [{ type: 'derivation', expression: 'formValue.startDate' }]
|
|
9890
|
+
|
|
9891
|
+
// CORRECT - targetProperty makes it a PROPERTY derivation
|
|
9892
|
+
logic: [{ type: 'derivation', targetProperty: 'minDate', expression: 'formValue.startDate' }]
|
|
9893
|
+
\`\`\`
|
|
9894
|
+
|
|
9895
|
+
\u274C Missing targetProperty:
|
|
9896
|
+
\`\`\`typescript
|
|
9897
|
+
// WRONG - must specify which property to set
|
|
9898
|
+
logic: [{ type: 'derivation', expression: '...' }]
|
|
9899
|
+
|
|
9900
|
+
// CORRECT
|
|
9901
|
+
logic: [{ type: 'derivation', targetProperty: 'options', expression: '...' }]
|
|
9902
|
+
\`\`\`
|
|
9903
|
+
|
|
9904
|
+
\u274C Forgetting targetProperty entirely:
|
|
9905
|
+
\`\`\`typescript
|
|
9906
|
+
// WRONG - this is a VALUE derivation (overwrites the field value)
|
|
9907
|
+
logic: [{ type: 'derivation', functionName: 'getCities' }]
|
|
9908
|
+
|
|
9909
|
+
// CORRECT - targetProperty makes it set the 'options' PROPERTY
|
|
9910
|
+
logic: [{ type: 'derivation', targetProperty: 'options', functionName: 'getCities' }]
|
|
8402
9911
|
\`\`\``
|
|
8403
9912
|
},
|
|
8404
9913
|
// ========== STANDARD PATTERNS ==========
|
|
@@ -8993,23 +10502,20 @@ const megaFormConfig = {
|
|
|
8993
10502
|
]
|
|
8994
10503
|
},
|
|
8995
10504
|
|
|
8996
|
-
// ARRAY
|
|
10505
|
+
// ARRAY (Simplified API - recommended)
|
|
8997
10506
|
{
|
|
8998
10507
|
key: 'itemsHeader', type: 'text', label: 'Dynamic Array:', props: { elementType: 'h3' }
|
|
8999
10508
|
},
|
|
9000
10509
|
{
|
|
9001
10510
|
key: 'demoArray',
|
|
9002
10511
|
type: 'array',
|
|
9003
|
-
|
|
9004
|
-
key: '
|
|
9005
|
-
type: '
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
]
|
|
9010
|
-
}]
|
|
10512
|
+
template: [
|
|
10513
|
+
{ key: 'itemName', type: 'input', label: 'Item Name' },
|
|
10514
|
+
{ key: 'itemQty', type: 'input', label: 'Qty', props: { type: 'number' } }
|
|
10515
|
+
],
|
|
10516
|
+
value: [{ itemName: 'Widget', itemQty: '5' }],
|
|
10517
|
+
addButton: { label: 'Add Item' }
|
|
9011
10518
|
},
|
|
9012
|
-
{ key: 'addItem', type: 'addArrayItem', label: 'Add Item', arrayKey: 'demoArray' },
|
|
9013
10519
|
|
|
9014
10520
|
{
|
|
9015
10521
|
key: 'containersNav',
|
|
@@ -9029,7 +10535,7 @@ const megaFormConfig = {
|
|
|
9029
10535
|
|
|
9030
10536
|
- **All field types:** input, select, radio, checkbox, multi-checkbox, textarea, toggle, slider, datepicker, text, hidden
|
|
9031
10537
|
- **All container types:** page, row, group, array
|
|
9032
|
-
- **All button types:** submit, next, previous,
|
|
10538
|
+
- **All button types:** submit, next, previous, auto-generated add/remove (simplified array)
|
|
9033
10539
|
- **Logic types:** hidden, required, derivation
|
|
9034
10540
|
- **Condition types:** fieldValue with operators
|
|
9035
10541
|
- **Layout:** col property for grid widths`;
|
|
@@ -9041,11 +10547,12 @@ function getPatternList() {
|
|
|
9041
10547
|
"Minimal Patterns (~20-50 lines)": [
|
|
9042
10548
|
"minimal-multipage",
|
|
9043
10549
|
"minimal-array",
|
|
10550
|
+
"minimal-simplified-array",
|
|
9044
10551
|
"minimal-conditional",
|
|
9045
10552
|
"minimal-validation",
|
|
9046
10553
|
"minimal-hidden"
|
|
9047
10554
|
],
|
|
9048
|
-
"Standard Patterns": ["derivation", "multi-page", "conditional", "validation"]
|
|
10555
|
+
"Standard Patterns": ["derivation", "property-derivation", "multi-page", "conditional", "validation"]
|
|
9049
10556
|
};
|
|
9050
10557
|
for (const [category, patterns] of Object.entries(categories)) {
|
|
9051
10558
|
lines.push(`## ${category}`);
|
|
@@ -9071,18 +10578,19 @@ Recommended starting points:
|
|
|
9071
10578
|
|
|
9072
10579
|
Minimal patterns (~20-50 lines):
|
|
9073
10580
|
- minimal-multipage: 2-page wizard
|
|
9074
|
-
- minimal-array: Dynamic list with add/remove
|
|
10581
|
+
- minimal-array: Dynamic list with add/remove (simplified API)
|
|
10582
|
+
- minimal-simplified-array: Simplified array with template + value
|
|
9075
10583
|
- minimal-conditional: Show/hide field
|
|
9076
10584
|
- minimal-validation: Password confirmation
|
|
9077
10585
|
- minimal-hidden: Hidden fields in multi-page
|
|
9078
10586
|
|
|
9079
|
-
Standard patterns: derivation, multi-page, conditional, validation
|
|
10587
|
+
Standard patterns: derivation, property-derivation, multi-page, conditional, validation
|
|
9080
10588
|
|
|
9081
10589
|
Use pattern="list" to see all available patterns.
|
|
9082
10590
|
Use depth="minimal" for code-only output (no markdown).`,
|
|
9083
10591
|
{
|
|
9084
10592
|
pattern: z55.string().optional().describe(
|
|
9085
|
-
'Pattern to get: minimal-multipage, minimal-array, minimal-conditional, minimal-validation, minimal-hidden, derivation, multi-page, conditional, validation, complete, mega. Use "list" to see all.'
|
|
10593
|
+
'Pattern to get: minimal-multipage, minimal-array, minimal-simplified-array, minimal-conditional, minimal-validation, minimal-hidden, derivation, property-derivation, multi-page, conditional, validation, complete, mega. Use "list" to see all.'
|
|
9086
10594
|
),
|
|
9087
10595
|
depth: z55.enum(["minimal", "brief", "full", "explained"]).default("full").describe(
|
|
9088
10596
|
"minimal=code only (~20-50 lines), brief=code+summary, full=code+comments (default), explained=code+detailed explanation"
|
|
@@ -9571,11 +11079,11 @@ var UI_INTEGRATIONS3 = ["material", "bootstrap", "primeng", "ionic"];
|
|
|
9571
11079
|
var FIX_SUGGESTIONS = {
|
|
9572
11080
|
options: "Move `options` from `props: { options: [...] }` to field level: `{ key, type, options: [...] }`",
|
|
9573
11081
|
label: "Remove `label` from this container field (page/group/row/array). Use a `text` field inside for headings.",
|
|
9574
|
-
logic: "
|
|
11082
|
+
logic: "Containers (group, row, array) only support 'hidden' logic type (same as pages). For other logic types (disabled, required, readonly, derivation), apply them to child fields instead.",
|
|
9575
11083
|
minValue: "Use `minValue` at field level for sliders, not `min` in props.",
|
|
9576
11084
|
maxValue: "Use `maxValue` at field level for sliders, not `max` in props.",
|
|
9577
11085
|
step: "Use `step` at field level for sliders, not in props.",
|
|
9578
|
-
template: "
|
|
11086
|
+
template: "Arrays support two APIs: (1) Full API with `fields` for explicit item definitions, or (2) Simplified API with `template` + `value` for common cases. If using `template`, provide a single field for primitive arrays or an array of fields for object arrays.",
|
|
9579
11087
|
content: "Use `label` for text content and `props: { elementType }` for HTML element.",
|
|
9580
11088
|
element: "Use `props: { elementType }` not `element` for text field HTML element.",
|
|
9581
11089
|
hideWhen: "Use `logic: [{ type: 'hidden', condition: {...} }]` - no `hideWhen` shorthand exists.",
|
|
@@ -9746,7 +11254,7 @@ Returns SPECIFIC error messages with:
|
|
|
9746
11254
|
Example errors you'll see:
|
|
9747
11255
|
- "Hidden field missing REQUIRED value property"
|
|
9748
11256
|
- "options MUST be at FIELD level, NOT inside props"
|
|
9749
|
-
- "
|
|
11257
|
+
- "containers only support 'hidden' logic type"`,
|
|
9750
11258
|
{
|
|
9751
11259
|
config: z56.union([z56.string(), z56.object({}).passthrough()]).describe(
|
|
9752
11260
|
"File path (.ts/.js) OR JSON string OR JSON object. Auto-detects: paths ending in .ts/.js or starting with / are treated as files."
|
|
@@ -9937,22 +11445,16 @@ ${indent} ]
|
|
|
9937
11445
|
${indent}}`;
|
|
9938
11446
|
}
|
|
9939
11447
|
function generateArray(name, indent) {
|
|
9940
|
-
|
|
11448
|
+
return `${indent}{
|
|
9941
11449
|
${indent} key: '${name}',
|
|
9942
11450
|
${indent} type: 'array',
|
|
9943
|
-
${indent}
|
|
9944
|
-
${indent} {
|
|
9945
|
-
${indent}
|
|
9946
|
-
${indent}
|
|
9947
|
-
${indent}
|
|
9948
|
-
${indent}
|
|
9949
|
-
${indent} { key: 'itemValue', type: 'input', label: 'Value' }
|
|
9950
|
-
${indent} ]
|
|
9951
|
-
${indent} }
|
|
9952
|
-
${indent} ]
|
|
11451
|
+
${indent} template: [
|
|
11452
|
+
${indent} { key: 'itemName', type: 'input', label: 'Name' },
|
|
11453
|
+
${indent} { key: 'itemValue', type: 'input', label: 'Value' }
|
|
11454
|
+
${indent} ],
|
|
11455
|
+
${indent} value: [],
|
|
11456
|
+
${indent} addButton: { label: 'Add ${capitalize(name)}' }
|
|
9953
11457
|
${indent}}`;
|
|
9954
|
-
const addButton = `${indent}{ key: 'add${capitalize(name)}', type: 'addArrayItem', label: 'Add ${capitalize(name)}', arrayKey: '${name}' }`;
|
|
9955
|
-
return [array, addButton];
|
|
9956
11458
|
}
|
|
9957
11459
|
function capitalize(s) {
|
|
9958
11460
|
return s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, " $1").trim();
|
|
@@ -9984,15 +11486,13 @@ function generateScaffold(options) {
|
|
|
9984
11486
|
const arraysForThisPage = pages > 2 && p > 1 && p < pages ? arrays : p === Math.ceil(pages / 2) ? arrays : [];
|
|
9985
11487
|
if (pages <= 2 && isFirst) {
|
|
9986
11488
|
for (const a of arrays) {
|
|
9987
|
-
const
|
|
11489
|
+
const arrayDef = generateArray(a, " ");
|
|
9988
11490
|
lines.push(arrayDef + ",");
|
|
9989
|
-
lines.push(addBtn + ",");
|
|
9990
11491
|
}
|
|
9991
11492
|
} else {
|
|
9992
11493
|
for (const a of arraysForThisPage) {
|
|
9993
|
-
const
|
|
11494
|
+
const arrayDef = generateArray(a, " ");
|
|
9994
11495
|
lines.push(arrayDef + ",");
|
|
9995
|
-
lines.push(addBtn + ",");
|
|
9996
11496
|
}
|
|
9997
11497
|
}
|
|
9998
11498
|
const fieldsPerPage = Math.ceil(fields.length / pages);
|
|
@@ -10036,9 +11536,8 @@ function generateScaffold(options) {
|
|
|
10036
11536
|
lines.push(generateGroup(g, " ") + ",");
|
|
10037
11537
|
}
|
|
10038
11538
|
for (const a of arrays) {
|
|
10039
|
-
const
|
|
11539
|
+
const arrayDef = generateArray(a, " ");
|
|
10040
11540
|
lines.push(arrayDef + ",");
|
|
10041
|
-
lines.push(addBtn + ",");
|
|
10042
11541
|
}
|
|
10043
11542
|
for (const f of fields) {
|
|
10044
11543
|
lines.push(generateField(f, " ") + ",");
|