Dynamic form builder for FLUSYS Angular applications
A frontend-only, fully dynamic Form Builder and Viewer system for Angular 21. Build forms visually, export/import JSON schemas, render forms dynamically, view submitted results, and export to PDF.
| Property | Value |
|----------|-------|
| Package | @flusys/ng-form-builder |
| Dependencies | @flusys/ng-core, @flusys/ng-shared |
| Peer Dependencies | @angular/cdk, pdfmake (optional) |
| Build Command | ng build ng-form-builder |
PDF Export (pdfmake): Required only if using PdfExportService.
``bash`
npm install pdfmake @types/pdfmake
---
Visual drag-and-drop form creation interface.
Selector: fb-form-builder
| Input | Type | Default | Description |
|-------|------|---------|-------------|
| schema | IFormSchema \| null | - | Schema to load |
| Output | Type | Description |
|--------|------|-------------|
| schemaChange | IFormSchema | Emits on every schema change |schemaSave
| | IFormSchema | Emits on save button click |schemaExport
| | string | Emits JSON string on export |
`typescript
import { FormBuilderComponent } from '@flusys/ng-form-builder';
@Component({
imports: [FormBuilderComponent],
template:
(schemaChange)="onSchemaChange($event)"
(schemaSave)="onSave($event)"
(schemaExport)="onExport($event)"
/>
})
export class MyBuilderPage {
readonly formSchema = signal
onSchemaChange(schema: IFormSchema): void {
console.log('Schema updated:', schema);
}
onSave(schema: IFormSchema): void {
this.apiService.saveForm(schema).subscribe();
}
onExport(json: string): void {
// JSON string of the schema
}
}
`
Features:
- Three-panel layout (palette | canvas | properties)
- Drag & drop fields from palette to canvas
- Section management (add, edit, delete, reorder, duplicate)
- Field configuration panel with tabs (General, Validation, Options, Logic)
- Flex/Grid layout options per section
- JSON import/export with error notifications (via optional MessageService)
Note: If you want error notifications during JSON import, provide MessageService from PrimeNG in a parent component:
`typescript
@Component({
providers: [MessageService],
imports: [FormBuilderComponent, ToastModule],
template: `
})
Runtime form rendering from JSON schema.
Selector: fb-form-viewer
| Input | Type | Default | Description |
|-------|------|---------|-------------|
| schema | IFormSchema | required | Form schema to render |initialValues
| | Record | {} | Pre-filled values |disabled
| | boolean | false | Disable all fields |showHeader
| | boolean | true | Show form title/description |showSubmit
| | boolean | true | Show submit button |submitLabel
| | string | 'Submit' | Submit button text |isSubmitting
| | boolean | false | Show loading state on submit |
| Output | Type | Description |
|--------|------|-------------|
| submitted | Record | Emits field values on submit |valueChanged
| | Record | Emits all values on any change |validityChanged
| | boolean | Emits form validity state |saveDraft
| | Record | Emits values on save draft |
`typescript
import { FormViewerComponent } from '@flusys/ng-form-builder';
@Component({
imports: [FormViewerComponent],
template:
[initialValues]="savedValues()"
[submitLabel]="'Submit Survey'"
[isSubmitting]="saving()"
(submitted)="onSubmit($event)"
(saveDraft)="onSaveDraft($event)"
/>
})
export class MySurveyPage {
readonly formSchema = signal
readonly savedValues = signal
readonly saving = signal(false);
onSubmit(values: Record
this.saving.set(true);
this.http.post('/api/submissions', values).subscribe(() => this.saving.set(false));
}
onSaveDraft(values: Record
sessionStorage.setItem('draft', JSON.stringify(values));
}
}
`
Features:
- Section-based navigation with steps
- Progress bar (configurable via schema settings)
- Real-time validation with error display
- Conditional logic evaluation (hide fields when conditions met)
- Draft saving support
- Disabled/readonly mode
Display submitted answers in a readable format.
Selector: fb-form-result-viewer
| Input | Type | Default | Description |
|-------|------|---------|-------------|
| schema | IFormSchema | required | Form schema |result
| | IFormSubmission | required | Submission data |showAnalytics
| | boolean | false | Show completion stats |
| Output | Type | Description |
|--------|------|-------------|
| fieldClick | { field, value } | Emits when a field is clicked |
`typescript
import { FormResultViewerComponent } from '@flusys/ng-form-builder';
@Component({
imports: [FormResultViewerComponent],
template:
[result]="submission()"
[showAnalytics]="true"
/>
`
})
export class SubmissionViewPage {
readonly formSchema = signal
readonly submission = signal
}
Features:
- Accordion-based section display
- ID-to-label mapping for all option fields
- Formatted display by field type (stars for rating, labels for likert, etc.)
- Analytics: total fields, answered count, completion percentage
- Draft/Completed status badge
---
| Type | Component | Category | Features |
|------|-----------|----------|----------|
| text | TextFieldComponent | input | Single/multi-line, placeholder, min/maxLength |number
| | NumberFieldComponent | input | Min/max, step, prefix/suffix, buttons |email
| | EmailFieldComponent | input | Email validation, custom pattern support |checkbox
| | CheckboxFieldComponent | selection | Boolean toggle, custom label |radio
| | RadioFieldComponent | selection | Single selection, vertical/horizontal layout |dropdown
| | DropdownFieldComponent | selection | Searchable, showClear option |multi_select
| | MultiSelectFieldComponent | selection | Multiple selection, chip/comma display |date
| | DateFieldComponent | specialized | Single/range/multiple, optional time, date format |likert
| | LikertFieldComponent | specialized | Matrix with configurable rows and scale |rating
| | RatingFieldComponent | specialized | Stars, half-star support, min/max labels |file_upload
| | FileUploadFieldComponent | specialized | Accept types, max files/size, multiple |
All selection fields (radio, dropdown, multi_select) use ID-based options:
`typescript
const field: IRadioField = {
id: 'q1',
type: FieldType.RADIO,
label: 'Are you satisfied?',
options: [
{ id: 'yes', label: 'Yes', order: 0 },
{ id: 'no', label: 'No', order: 1 },
{ id: 'maybe', label: 'Maybe', order: 2 }
]
};
// Submission value: { q1: 'yes' } (ID, not label)
`
Fields support width control within layouts:
| Width | Description |
|-------|-------------|
| full | 100% width (spans all columns) |half
| | 50% width |third
| | 33% width |quarter
| | 25% width |
---
`json`
{
"id": "survey-001",
"version": "1.0.0",
"name": "Customer Feedback Survey",
"description": "Please share your experience",
"settings": {
"allowSaveDraft": true,
"showProgressBar": true,
"showSectionNav": true,
"submitButtonText": "Submit Survey"
},
"sections": [
{
"id": "sec_1",
"name": "Personal Information",
"description": "Basic details",
"order": 0,
"layout": {
"type": "grid",
"config": { "columns": 2, "gap": "1rem" }
},
"fields": [
{
"id": "q1",
"type": "text",
"name": "fullName",
"label": "Full Name",
"placeholder": "Enter your name",
"required": true,
"order": 0,
"width": "full",
"validation": {
"rules": [
{ "type": "required", "message": "Name is required" },
{ "type": "min_length", "value": 2, "message": "Min 2 characters" }
]
}
},
{
"id": "q2",
"type": "email",
"name": "email",
"label": "Email Address",
"required": true,
"order": 1,
"width": "half"
}
]
},
{
"id": "sec_2",
"name": "Feedback",
"order": 1,
"layout": {
"type": "flex",
"config": { "direction": "column", "gap": "1rem", "wrap": true }
},
"fields": [
{
"id": "q3",
"type": "rating",
"name": "satisfaction",
"label": "Overall Satisfaction",
"maxRating": 5,
"required": true,
"order": 0
},
{
"id": "q4",
"type": "radio",
"name": "recommend",
"label": "Would you recommend us?",
"displayLayout": "horizontal",
"options": [
{ "id": "yes", "label": "Yes", "order": 0 },
{ "id": "no", "label": "No", "order": 1 }
],
"order": 1
},
{
"id": "q5",
"type": "text",
"name": "reason",
"label": "Please tell us why",
"rows": 3,
"order": 2,
"logicRule": {
"action": "hide",
"logic": {
"operator": "AND",
"conditions": [
{ "fieldId": "q4", "comparison": "not_equals", "value": "no" }
]
}
}
}
]
}
]
}
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| allowSaveDraft | boolean | true | Show save draft button |showProgressBar
| | boolean | true | Show progress bar |showSectionNav
| | boolean | true | Show section step navigation |shuffleQuestions
| | boolean | false | Randomize question order |submitButtonText
| | string | 'Submit' | Submit button label |
---
Fields are visible by default. Use logic rules to hide or require fields conditionally.
`typescript
interface IConditionalRule {
action: LogicAction; // 'hide' | 'require' | 'jump'
logic: ILogicGroup;
targetId?: string; // For section-level rules targeting fields or sections
targetType?: 'field' | 'section';
}
interface ILogicGroup {
operator: LogicOperator; // 'AND' | 'OR'
conditions: Array
}
interface ICondition {
fieldId: string;
comparison: ComparisonOperator;
value: unknown;
}
`
| Action | Level | Description |
|--------|-------|-------------|
| hide | Field/Section | Hide field when conditions are met (default: visible) |require
| | Field/Section | Make field required when conditions are met |jump
| | Section only | Jump to target section when conditions are met |
| Operator | Description |
|----------|-------------|
| equals | Value equals target |not_equals
| | Value does not equal target |contains
| | String/array contains value |not_contains
| | String/array does not contain value |greater_than
| | Number greater than |less_than
| | Number less than |greater_or_equal
| | Number greater than or equal |less_or_equal
| | Number less than or equal |is_empty
| | Value is null/undefined/empty |is_not_empty
| | Value has content |in
| | Value is in array |not_in
| | Value is not in array |
Hide a field unless certain conditions are met (inverse logic since default is visible):
`json`
{
"id": "reason_field",
"type": "text",
"label": "Please explain",
"logicRule": {
"action": "hide",
"logic": {
"operator": "OR",
"conditions": [
{ "fieldId": "q1", "comparison": "not_equals", "value": "no" },
{ "fieldId": "q2", "comparison": "greater_or_equal", "value": 3 }
]
}
}
}
This hides the field when q1 is NOT "no" OR q2 is >= 3 (showing only when q1="no" AND q2 < 3).
---
| Type | Value | Description |
|------|-------|-------------|
| required | - | Field must have a value |min_length
| | number | Minimum string length |max_length
| | number | Maximum string length |min
| | number | Minimum numeric value |max
| | number | Maximum numeric value |pattern
| | string | Regex pattern |email
| | - | Valid email format |custom
| | string | Custom validator name |
`json`
{
"validation": {
"rules": [
{ "type": "required", "message": "This field is required" },
{ "type": "min_length", "value": 5, "message": "Min 5 characters" },
{ "type": "pattern", "value": "^[A-Z].*", "message": "Must start with uppercase" }
]
}
}
Email fields support custom regex patterns for domain restrictions:
`json`
{
"id": "work_email",
"type": "email",
"label": "Work Email",
"pattern": "@company\\.com$",
"patternMessage": "Please use your company email"
}
Common patterns:
- @company\\.com$ - Single domain@(company1|company2)\\.com$
- - Multiple domains@.*\\.gov$
- - Government emails
`typescript
import { ValidationService } from '@flusys/ng-form-builder';
// Register custom validator
validationService.registerCustomValidator('phone', (value, field) => {
if (typeof value !== 'string') return 'Invalid phone';
const phoneRegex = /^\+?[\d\s-]{10,}$/;
return phoneRegex.test(value) ? null : 'Invalid phone number';
});
// Use in schema
{
"validation": {
"rules": [
{ "type": "custom", "value": "phone" }
]
}
}
`
---
`json`
{
"layout": {
"type": "flex",
"config": {
"direction": "column",
"wrap": true,
"gap": "1rem",
"alignItems": "stretch",
"justifyContent": "flex-start"
}
}
}
`json`
{
"layout": {
"type": "grid",
"config": {
"columns": 2,
"gap": "1rem",
"rowGap": "1.5rem",
"columnGap": "1rem"
}
}
}
---
The package follows Angular 21 signal best practices:
`typescript`
// Service pattern
private readonly _registry = signal
`typescript
// Use viewChildren() instead of @ViewChildren decorator
private readonly tooltips = viewChildren(Tooltip);
// Access via function call
this.tooltips().forEach(tooltip => tooltip.deactivate());
`
`typescript
// Instead of effect with allowSignalWrites, use computed with override signal
private readonly _collapsedOverride = signal
readonly collapsed = computed(() => {
const override = this._collapsedOverride();
if (override !== null) return override;
return this.section().isCollapsed ?? false;
});
toggleCollapsed(): void {
this._collapsedOverride.set(!this.collapsed());
}
`
`typescript
// All component UI state should use signals
readonly activeTab = signal
readonly searchQuery = signal
// Template binding with signals
// [value]="activeTab()" (valueChange)="onTabChange($event)"
`
Important: Initialize signals with data directly rather than updating in constructors for reliable rendering in zoneless Angular:
`typescript
// CORRECT - Initialize with data
private readonly _registry = signal(this.createInitialRegistry());
// AVOID - Update in constructor (can cause timing issues)
private readonly _registry = signal(new Map());
constructor() {
this.populateRegistry(); // Signal update may not propagate
}
`
---
Manages builder UI state. Provided at component level by FormBuilderComponent.
Key Signals:
- schema() / isDirty()selectedSection()
- / selectedField() / allFields()sections()
- / formName() / formSettings()
Schema Operations:
- loadSchema(schema) / createNewSchema(name) / updateFormMeta(updates) / markAsSaved()
Section Operations:
- addSection() / updateSection(id, updates) / deleteSection(id)reorderSections(from, to)
- / duplicateSection(id)
Field Operations:
- addField(sectionId, fieldType) / updateField(sectionId, fieldId, updates)deleteField(sectionId, fieldId)
- / reorderFields(sectionId, from, to)moveFieldToSection(fromId, toId, fieldId, toIndex)
- / duplicateField(sectionId, fieldId)
Selection:
- selectSection(id) / selectField(id) / clearSelection()
Evaluates conditional visibility rules. Provided at component level.
`typescript
import { ConditionalLogicService } from '@flusys/ng-form-builder';
@Component({ providers: [ConditionalLogicService] })
export class MyComponent {
private readonly logicService = inject(ConditionalLogicService);
checkVisibility(field: IField): boolean {
return this.logicService.evaluateFieldVisibility(field);
}
updateValue(fieldId: string, value: unknown): void {
this.logicService.updateFormValue(fieldId, value);
}
}
`
Validates field values and form submissions. Provided at root. Uses immutable internal state with readonly collections.
`typescript
import { ValidationService } from '@flusys/ng-form-builder';
// Validate single field
const error = validationService.validateField(field, value); // string | null
// Validate section
const errors = validationService.validateSection(section, values); // Map
// Validate entire form
const allErrors = validationService.validateForm(sections, values); // IValidationError[]
// Register custom validators
validationService.registerCustomValidator('phone', (value, field) => {
if (typeof value !== 'string') return 'Invalid phone';
return /^\+?[\d\s-]{10,}$/.test(value) ? null : 'Invalid phone number';
});
`
Import/export JSON schemas with validation. Provided at root.
`typescript
import { SchemaExportService } from '@flusys/ng-form-builder';
// Export
schemaExportService.exportToFile(schema, 'my-form.json');
const json = schemaExportService.exportToJson(schema, true); // pretty-printed
// Import from JSON string
const schema = schemaExportService.importFromJson(jsonString);
// Import from File object
const schemaFromFile = await schemaExportService.importFromFile(file);
// Validate
const { valid, errors } = schemaExportService.validateSchema(schema);
`
Export form submissions to PDF. Provided at root. Requires pdfmake (optional peer dependency).
`typescript
import { PdfExportService, IPdfExportConfig } from '@flusys/ng-form-builder';
const config: IPdfExportConfig = {
title: 'Survey Results',
subtitle: 'Customer Feedback Q1 2026',
showSummary: true,
primaryColor: '#2563EB',
pageSize: 'A4',
};
// Download PDF file
await pdfService.downloadPdf(schema, submission, config);
// Open in new tab
await pdfService.openPdf(schema, submission, config);
// Get as Blob
const blob = await pdfService.getPdfBlob(schema, submission, config);
`
PDF Configuration (IPdfExportConfig):
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| title | string | Form name | Document title |subtitle
| | string | - | Subtitle text |showMetadata
| | boolean | true | Show submission date/version |showDescription
| | boolean | true | Show form description |showSectionDescriptions
| | boolean | true | Show section descriptions |showSummary
| | boolean | true | Show summary statistics |primaryColor
| | string | #2563EB | Header/accent color (hex) |accentColor
| | string | #10B981 | Secondary color (hex) |pageSize
| | string | A4 | A4, LETTER, LEGAL |pageOrientation
| | string | portrait | portrait or landscape |footerText
| | string | Generated by Form Builder | Footer text |
Extensible field type registry. Provided at root. Built-in types auto-registered at creation time for reliable zoneless rendering.
`typescript
import { FieldRegistryService } from '@flusys/ng-form-builder';
// Signal-based reactive access
const all = fieldRegistry.allFieldTypes(); // Signal
const inputs = fieldRegistry.inputFieldTypes(); // Computed signal
const selections = fieldRegistry.selectionFieldTypes();
const specialized = fieldRegistry.specializedFieldTypes();
// Method-based access (imperative)
const all = fieldRegistry.getAll();
const inputs = fieldRegistry.getByCategory('input');
// Register custom field type
fieldRegistry.register({
type: 'custom_type',
label: 'Custom Field',
icon: 'pi pi-star',
category: 'specialized',
component: MyCustomFieldComponent,
});
`
Implementation Note: The registry initializes built-in types directly in the signal definition (createInitialRegistry()) rather than in the constructor to ensure data is available synchronously for zoneless Angular.
---
`typescript
import { sortByOrder, sortFields, sortSections, sortOptions } from '@flusys/ng-form-builder';
const sorted = sortFields(fields); // Sort by order property
const sorted = sortSections(sections); // Sort by order property
const sorted = sortOptions(options); // Sort by order property
`
`typescript
import { isEmpty, hasValue, formatFileSize } from '@flusys/ng-form-builder';
isEmpty(null); // true
isEmpty(''); // true
isEmpty([]); // true
isEmpty({}); // true
isEmpty('hello'); // false
hasValue('hello'); // true (inverse of isEmpty)
formatFileSize(1024); // '1 KB'
formatFileSize(1048576); // '1 MB'
`
`typescript
import { createFormSchema, createSection } from '@flusys/ng-form-builder';
// Create schema with defaults (generates ID, version, timestamps)
const schema = createFormSchema({ name: 'My Form' });
// Create section with defaults (generates ID, empty fields, default layout)
const section = createSection({ name: 'Section 1' });
`
`typescript
import { hasOptions, isLogicGroup } from '@flusys/ng-form-builder';
// Check if field has options (radio, dropdown, multi_select)
if (hasOptions(field)) {
console.log(field.options);
}
// Check if condition item is a nested logic group
if (isLogicGroup(item)) {
console.log(item.operator, item.conditions);
}
`
`typescript`
import {
DEFAULT_FORM_SETTINGS,
DEFAULT_SECTION_LAYOUT,
DEFAULT_FLEX_LAYOUT,
DEFAULT_GRID_LAYOUT,
FIELD_TYPE_INFO,
} from '@flusys/ng-form-builder';
---
| Class | Description |
|-------|-------------|
| .fb-form-builder | Main builder container |.fb-field
| | Field wrapper |.fb-field-label
| | Field label |.fb-help-text
| | Help text below field |.fb-error
| | Error message |.fb-required
| | Required indicator (*) |.fb-section
| | Section container |
`scss
.fb-field {
margin-bottom: 1.5rem;
&.has-error {
.fb-field-label {
color: var(--red-500);
}
}
}
.fb-section {
background: var(--surface-card);
border-radius: var(--border-radius);
padding: 1.5rem;
}
`
---
Do:
- Use ID-based options for all selection fields
- Add meaningful validation messages
- Use conditional logic to simplify complex forms
- Export schemas for backup/versioning
- Use sections to organize long forms
- Set appropriate field widths for better layouts
Don't:
- Don't use labels as option values (use IDs)
- Don't create circular conditional logic dependencies
- Don't mix validation in UI code (use schema validation rules)
- Don't modify schema structure outside the builder service
Do:
- Initialize signals with data directly (not in constructors)
- Use viewChildren() instead of @ViewChildren decoratoreffect
- Use computed override pattern instead of with allowSignalWritesreadonly
- Mark all service collections with modifierinput()
- Use , output() instead of decorators[value]="signal()"
- Split two-way bindings: + (valueChange)="onHandler($event)"
Don't:
- Don't update signals in constructors (causes zoneless timing issues)
- Don't use allowSignalWrites: true in effects (use computed pattern instead)@ViewChildren
- Don't use /@ViewChild decorators (use signal queries)[(ngModel)]
- Don't use with signals (split the binding)
---
The package provides ready-to-use pages for form management with backend API integration.
`typescript
import { FORM_BUILDER_ADMIN_ROUTES } from '@flusys/ng-form-builder';
// In your app routes (requires authentication)
{
path: 'forms/manage',
loadChildren: () => import('@flusys/ng-form-builder').then(m => m.FORM_BUILDER_ADMIN_ROUTES)
}
`
`typescript
import { FORM_BUILDER_PUBLIC_ROUTES } from '@flusys/ng-form-builder';
// In your app routes (public access)
{
path: 'forms/public',
loadChildren: () => import('@flusys/ng-form-builder').then(m => m.FORM_BUILDER_PUBLIC_ROUTES)
}
`
| Page | Route | Description |
|------|-------|-------------|
| Form List | /forms/manage | List all forms with CRUD operations |/forms/manage/:id
| Form Details | | Form builder, settings, and results |/forms/manage/new
| Form Details (New) | | Create new form |/forms/manage/:id/results/:resultId
| Result Viewer | | View single submission |/forms/public/:id
| Public Form | | Public form submission page |
| Service | Description |
|---------|-------------|
| FormApiService | Form CRUD, access info, public/authenticated form endpoints |FormResultApiService
| | Submit forms, get results by form ID |
---
`bashBuild the package
cd FLUSYS_NG && npm run build:ng-form-builder
---
- NestJS Form Builder Guide - Backend API reference
- Angular Signals Guide
- Angular Components Guide
- Shared Guide - Provider interfaces
---
Last Updated: 2026-02-09
Angular Version: 21
Package Version: 1.1.0
Refactored: API integration, admin/public pages, POST-only RPC pattern