Dynamic FieldArray

A realistic dynamic shopping cart built entirely with Preforms.

This example demonstrates how to use FieldArray, FormRow, OutputField for real-time calculations, and the powerful aggregates system to enforce business rules all declaratively.

Users can add up to 8 items, remove items, edit product names, prices, and quantities. Subtotals and the grand total update automatically. Validation prevents duplicate product names and ensures the cart total does not exceed $2000.

Features Demonstrated

  • Dynamic array of items with add and remove buttons
  • Per-item real-time subtotal calculation
  • Grand total calculation using JavaScript expressions
  • Cross-item validation using aggregates
  • Clean tabular layout with FormRow
  • Initial value seeding

How It Works

1 FieldArray

The FieldArray manages a dynamic list of cart items.

  • minItems: 1 and maxItems: 8 enforce limits.
  • addButton and removeButton provide built-in UI controls.
  • value seeds the array with an initial item.

2. Per-Item Row (FormRow)

Each row contains:

  • TextField for product name
  • NumberField for price and quantity
  • OutputField for subtotal (read-only)

The subtotal uses the $index placeholder to reference the current array item.

3. Real-Time Calculations

  • Subtotal (inside array): Number(items[$index].price) * Number(items[$index].qty)
  • Grand Total (outside array): Uses .reduce() over all items with the [*] wildcard to watch every price and quantity. The for property tells Preforms which fields should trigger recalculation.

4. Aggregates (Advanced Validation)

Aggregates let you define rules over the entire array without writing custom validators.

TypeScriptaggregates: [
{
  action: 'product',           // Special action: Σ(price × qty) across all items
  field: ['price', 'qty'],     // Fields to multiply per item
  value: 2000,
  operator: 'lte',             // ≤
  message: 'Cart total cannot exceed $2000'
},
{
  action: 'unique',
  field: 'name',
  message: 'Product names must be unique'
}
];

Supported Aggregate Actions:

  • "sum", "count", "avg" — standard numerical aggregations
  • "product" — multiplies two fields per item then aggregates the results (ideal for price × quantity scenarios)
  • "unique" — ensures all values in the specified field are distinct

Live Demo

Shopping Cart

Result

import { Component, EventEmitter, Output, ViewEncapsulation } from '@angular/core';
import { Preforms } from '@preforms/angular/core';
import { NATIVE_FORM_ELEMENTS } from '@preforms/angular/native';
import {
  FieldArray,
  FormRow,
  TextField,
  NumberField,
  OutputField,
  FormDivider,
  SubmitButton,
  FieldButton,
  DialogField,
  TextareaField,
  Aggregate,
  FormGrid,
} from '@preforms/ts';

@Component({
  selector: 'app-cart-builder',
  template: `
    <preforms className="cart-form" (submittedData)="logData($event)" [fields]="fields" />
  `,
  styleUrl: './cart-builder.component.scss',
  imports: [Preforms],
  encapsulation: ViewEncapsulation.None,
  providers: [NATIVE_FORM_ELEMENTS],
})
export class CartBuilderComponent {
  @Output() formChange = new EventEmitter<any>();

  fields = [
    new FieldArray({
      key: 'items',
      label: 'Shopping Cart',
      minItems: 1,
      maxItems: 8,
      addButton: true,
      removeButton: true,

      value: [{ name: 'Wireless Mouse', price: 25, qty: 1 }],

      fields: [
        new FormGrid({
          className: 'cart-row',
          gap: '1rem',
          fields: [
            new TextField({
              key: 'name',
              placeholder: 'Product name',
              required: true,
            }),

            new NumberField({
              key: 'price',
              label: 'Price',
              min: 1,
              value: 0,
              required: true,
            }),

            new NumberField({
              key: 'qty',
              label: 'Qty',
              min: 1,
              value: 1,
              required: true,
            }),

            new FieldButton({
              type: 'button',
              label: 'Comment',
              command: 'show-modal',
              commandfor: 'comment-dialog[$index]',
              className: 'comment-btn',
            }),

            new OutputField({
              key: 'totalPrice',
              calculation:
                '"$"+(Number(items[$index].price) * Number(items[$index].qty)).toFixed(2)',
              for: ['price[$index]', 'qty[$index]'],
              className: 'subtotal',
            }),

            new DialogField({
              id: 'comment-dialog[$index]',
              fields: [
                new TextareaField({
                  key: 'comment',
                  rows: 5,
                }),

                new FormRow([
                  new SubmitButton('Save'),

                  new FieldButton({
                    label: 'Close',
                    type: 'button',
                    command: 'close',
                    commandfor: 'comment-dialog[$index]',
                  }),
                ]),
              ],
            }),
          ],
          columns: '1fr 1fr 1fr 1fr',
        }),
      ],

      aggregates: [
        // Special action: Σ(price × qty) across all items
        Aggregate.product({
          field: ['price', 'qty'], // Fields to multiply per item
          value: 2000,
          operator: 'lte',
          message: 'total cannot exceed $2000',
        }),

        Aggregate.unique('name'),
      ],
    }),

    new FormDivider({ label: 'Summary' }),

    new OutputField({
      key: 'total',
      calculation: `
        "$" + items.reduce(
          (sum, i) => sum + (Number(i.price) * Number(i.qty)),
          0
        )
      `,
      for: ['price[*]', 'qty[*]', 'items'],
    }),

    new SubmitButton({
      label: 'Checkout',
      className: 'checkout-btn',
    }),
  ];

  logData(data: any) {
    this.formChange.emit(data);
  }
}