Iterator

A component for creating repeating form sections. Use it when users need to input multiple instances of the same data structure, such as adding order lines, addresses, or contact details.

ctx.ui.iterator("name", config);

Config

name*
string

The name of the iterator. This will be the key in the result object, containing an array of values from each iteration.


content*
UIElement[]

An array of UI elements to repeat for each item. These can be any input or display elements. Each iteration renders a copy of these elements.


min
number

The minimum number of items required. If set, the user cannot submit until at least this many items exist.


max
number

The maximum number of items allowed. Once reached, the user cannot add more items.


validate
(data: T[]) => true | string

A validation function that receives the full array of item data. Return true if valid, or an error message string.

Usage

The iterator creates repeatable form sections within a page. Users can add and remove items dynamically, and each item contains the same set of input fields.

const result = await ctx.ui.page({
  title: "Add order lines",
  content: [
    ctx.ui.iterator("lines", {
      content: [
        ctx.ui.inputs.text("productCode", {
          label: "Product code",
        }),
        ctx.ui.inputs.number("quantity", {
          label: "Quantity",
        }),
      ],
      min: 1,
    }),
  ],
});
 
// result.lines is an array of objects
for (const line of result.lines) {
  console.log(`${line.productCode}: ${line.quantity}`);
}

The result is an array where each element contains the values from one iteration:

result.lines = [
  { productCode: "SKU-001", quantity: 10 },
  { productCode: "SKU-002", quantity: 5 },
  { productCode: "SKU-003", quantity: 2 },
];

Adding and removing items

The iterator renders add and remove controls for each item. Users can:

  • Click the Add button to create a new item with empty fields
  • Click the Remove button on any item to delete it

When min is set, users cannot remove items below the minimum count. When max is set, the add button is disabled once the limit is reached.

ctx.ui.iterator("contacts", {
  content: [
    ctx.ui.inputs.text("name", { label: "Name" }),
    ctx.ui.inputs.text("email", { label: "Email" }),
  ],
  min: 1,  // At least one contact required
  max: 5,  // No more than five contacts
});

Mixing display and input elements

You can include display elements alongside inputs to provide context within each iteration:

ctx.ui.iterator("items", {
  content: [
    ctx.ui.display.header({
      title: "Item details",
      level: 3,
    }),
    ctx.ui.inputs.text("sku", { label: "SKU" }),
    ctx.ui.inputs.number("quantity", { label: "Quantity" }),
    ctx.ui.inputs.text("notes", {
      label: "Notes",
      multiline: true,
    }),
  ],
});

Select elements in iterators

Select elements work within iterators. The selected value is captured for each iteration independently:

ctx.ui.iterator("orderLines", {
  content: [
    ctx.ui.select.one("product", {
      label: "Product",
      options: [
        { label: "Widget A", value: "widget-a" },
        { label: "Widget B", value: "widget-b" },
        { label: "Gadget Pro", value: "gadget-pro" },
      ],
    }),
    ctx.ui.inputs.number("quantity", { label: "Qty" }),
  ],
});

Validation

Per-item validation

Each input element within the iterator can have its own validate function. Validation runs independently for each item:

ctx.ui.iterator("lines", {
  content: [
    ctx.ui.inputs.text("sku", {
      label: "SKU",
      validate: (value) => {
        if (!value || value.length < 3) {
          return "SKU must be at least 3 characters";
        }
        return true;
      },
    }),
    ctx.ui.inputs.number("quantity", {
      label: "Quantity",
      validate: (value) => {
        if (value <= 0) {
          return "Quantity must be greater than zero";
        }
        return true;
      },
    }),
  ],
});

Validation errors are displayed on the specific field within the specific item that failed.

Collection-level validation

Use the iterator's validate function to validate the entire collection:

ctx.ui.iterator("lines", {
  content: [
    ctx.ui.inputs.text("sku", { label: "SKU" }),
    ctx.ui.inputs.number("quantity", { label: "Quantity" }),
  ],
  validate: (lines) => {
    // Check for duplicate SKUs
    const skus = lines.map((line) => line.sku);
    const uniqueSkus = new Set(skus);
    if (uniqueSkus.size !== skus.length) {
      return "Duplicate SKUs are not allowed";
    }
 
    // Check total quantity
    const totalQty = lines.reduce((sum, line) => sum + line.quantity, 0);
    if (totalQty > 1000) {
      return "Total quantity cannot exceed 1000";
    }
 
    return true;
  },
});

Example: Creating order lines

A common ERP use case is creating multiple order lines for a sales or purchase order:

flows/createOrder.ts
import { CreateOrder, models } from "@teamkeel/sdk";
 
export default CreateOrder(async (ctx, inputs) => {
  // Step 1: Select customer
  const customerResult = await ctx.ui.page({
    title: "Select customer",
    content: [
      ctx.ui.select.one("customerId", {
        label: "Customer",
        options: await getCustomerOptions(),
      }),
    ],
  });
 
  // Step 2: Add order lines using iterator
  const linesResult = await ctx.ui.page({
    title: "Add order lines",
    content: [
      ctx.ui.iterator("lines", {
        content: [
          ctx.ui.select.one("productId", {
            label: "Product",
            options: await getProductOptions(),
          }),
          ctx.ui.inputs.number("quantity", {
            label: "Quantity",
            min: 1,
            validate: (qty) => {
              if (qty <= 0) return "Quantity must be positive";
              return true;
            },
          }),
          ctx.ui.inputs.number("unitPrice", {
            label: "Unit price",
            min: 0,
          }),
          ctx.ui.inputs.text("notes", {
            label: "Line notes",
            optional: true,
          }),
        ],
        min: 1, // At least one line required
        validate: (lines) => {
          if (lines.length === 0) {
            return "At least one order line is required";
          }
 
          // Check for duplicate products
          const productIds = lines.map((l) => l.productId);
          if (new Set(productIds).size !== productIds.length) {
            return "Each product can only appear once";
          }
 
          return true;
        },
      }),
    ],
  });
 
  // Create the order
  const order = await models.order.create({
    customerId: customerResult.customerId,
    status: "Draft",
    createdAt: new Date(),
  });
 
  // Create order lines
  for (const line of linesResult.lines) {
    await models.orderLine.create({
      orderId: order.id,
      productId: line.productId,
      quantity: line.quantity,
      unitPrice: line.unitPrice,
      notes: line.notes,
    });
  }
 
  return ctx.ui.complete({
    title: "Order created",
    content: [
      ctx.ui.display.keyValue({
        data: [
          { key: "Order ID", value: order.id },
          { key: "Lines", value: linesResult.lines.length },
        ],
      }),
    ],
  });
});
 
async function getCustomerOptions() {
  const customers = await models.customer.findMany();
  return customers.map((c) => ({
    label: c.name,
    value: c.id,
  }));
}
 
async function getProductOptions() {
  const products = await models.product.findMany();
  return products.map((p) => ({
    label: `${p.sku} - ${p.name}`,
    value: p.id,
  }));
}

Example: Multiple addresses

Capture multiple shipping addresses for an account:

flows/updateAddresses.ts
import { UpdateAddresses, models } from "@teamkeel/sdk";
 
export default UpdateAddresses(async (ctx, inputs) => {
  const result = await ctx.ui.page({
    title: "Manage addresses",
    content: [
      ctx.ui.iterator("addresses", {
        content: [
          ctx.ui.select.one("type", {
            label: "Address type",
            options: ["Billing", "Shipping", "Warehouse"],
          }),
          ctx.ui.inputs.text("line1", { label: "Address line 1" }),
          ctx.ui.inputs.text("line2", {
            label: "Address line 2",
            optional: true,
          }),
          ctx.ui.inputs.text("city", { label: "City" }),
          ctx.ui.inputs.text("postcode", { label: "Postcode" }),
          ctx.ui.inputs.text("country", { label: "Country" }),
          ctx.ui.inputs.boolean("isDefault", {
            label: "Set as default",
            defaultValue: false,
          }),
        ],
        min: 1,
        validate: (addresses) => {
          // Ensure at least one default address if multiple exist
          const defaults = addresses.filter((a) => a.isDefault);
          if (addresses.length > 1 && defaults.length === 0) {
            return "Please set at least one address as default";
          }
          return true;
        },
      }),
    ],
  });
 
  // Delete existing addresses and recreate
  await models.address.deleteMany({
    where: { accountId: inputs.accountId },
  });
 
  for (const addr of result.addresses) {
    await models.address.create({
      accountId: inputs.accountId,
      type: addr.type,
      line1: addr.line1,
      line2: addr.line2,
      city: addr.city,
      postcode: addr.postcode,
      country: addr.country,
      isDefault: addr.isDefault,
    });
  }
 
  return { addressCount: result.addresses.length };
});