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
stringThe name of the iterator. This will be the key in the result object, containing an array of values from each iteration.
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.
numberThe minimum number of items required. If set, the user cannot submit until at least this many items exist.
numberThe maximum number of items allowed. Once reached, the user cannot add more items.
(data: T[]) => true | stringA 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:
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:
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 };
});