Input.datagrid

Data grid input

An editable spreadsheet-like input for tabular data entry. Use it when users need to enter or modify multiple rows of structured data.

ctx.ui.inputs.dataGrid("name", config);

Config

name*
string

The name of the input. This will be the key in the result object.


helpText
string

Help text displayed below the data grid.


data*
T[]

An array of objects representing the initial rows. Each object's keys become columns.


columns
ColumnConfig[]

Explicit column configuration. If not provided, columns are inferred from the data with all fields editable.


allowAddRows
boolean

Whether users can add new rows. Default is false.


allowDeleteRows
boolean

Whether users can delete rows. Default is false.


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

A validation function that receives the current data and returns true if valid, or an error message string if invalid.

Column configuration

Each column in the columns array accepts these properties:

PropertyTypeRequiredDescription
keystringYesThe property name in your data objects
labelstringNoDisplay label for the column header. Defaults to the key
type"text" | "number" | "boolean" | "id"NoData type for the column. Inferred from data if not specified
editablebooleanNoWhether cells in this column can be edited. Defaults to true (except id type which defaults to false)

Column types

TypeDescription
textString values
numberNumeric values
booleanCheckbox (true/false)
idNon-editable identifier field

Usage

The data grid returns an array of objects matching the configured columns. Add it to a page like any other input element.

const result = await ctx.ui.page({
  title: "Enter order lines",
  content: [
    ctx.ui.inputs.dataGrid("orderLines", {
      data: [
        { sku: "SKU-001", description: "Widget A", quantity: 10 },
        { sku: "SKU-002", description: "Widget B", quantity: 5 },
      ],
      columns: [
        { key: "sku", label: "SKU", type: "text" },
        { key: "description", label: "Description", type: "text" },
        { key: "quantity", label: "Qty", type: "number" },
      ],
      allowAddRows: true,
      allowDeleteRows: true,
    }),
  ],
});
 
// result.orderLines is an array of { sku: string, description: string, quantity: number }
for (const line of result.orderLines) {
  console.log(`${line.sku}: ${line.quantity}`);
}

Inferred columns

If you don't provide a columns configuration, the data grid infers columns from your data. Column types are determined by the JavaScript type of each value, and labels are generated from keys using sentence case.

const result = await ctx.ui.page({
  title: "Products",
  content: [
    ctx.ui.inputs.dataGrid("products", {
      data: [
        { name: "Widget A", quantity: 10, inStock: true },
        { name: "Widget B", quantity: 5, inStock: false },
      ],
      // Columns inferred as: Name (text), Quantity (number), In stock (boolean)
    }),
  ],
});

Validation

Use the validate function to enforce business rules on the data. The function receives the current array of rows and should return true if valid, or an error message string.

ctx.ui.inputs.dataGrid("inventory", {
  data: [
    { sku: "SKU001", quantity: 10, price: 99.99 },
    { sku: "SKU002", quantity: 5, price: 149.99 },
  ],
  columns: [
    { key: "sku", label: "SKU", type: "text" },
    { key: "quantity", label: "Qty", type: "number" },
    { key: "price", label: "Price", type: "number" },
  ],
  allowAddRows: true,
  allowDeleteRows: true,
  validate: (data) => {
    if (data.length === 0) {
      return "At least one item is required";
    }
 
    const hasInvalidQty = data.some((item) => item.quantity < 0);
    if (hasInvalidQty) {
      return "Quantities must be non-negative";
    }
 
    const hasInvalidPrice = data.some((item) => item.price <= 0);
    if (hasInvalidPrice) {
      return "All prices must be greater than zero";
    }
 
    return true;
  },
});

Read-only columns

Set editable: false on a column to prevent users from modifying its values. Columns with type: "id" are non-editable by default.

ctx.ui.inputs.dataGrid("orders", {
  data: [
    { id: "ORD-001", customer: "Acme Corp", status: "Pending" },
    { id: "ORD-002", customer: "GlobalTech", status: "Complete" },
  ],
  columns: [
    { key: "id", label: "Order ID", type: "id" },  // Non-editable by default
    { key: "customer", label: "Customer", type: "text" },
    { key: "status", label: "Status", type: "text", editable: false },
  ],
  allowAddRows: false,
  allowDeleteRows: false,
});

Example: Stock count entry

A flow for entering stock count results during inventory checks:

import { StockCount, FlowConfig } from "@teamkeel/sdk";
 
const config: FlowConfig = {
  name: "Stock Count",
};
 
export default StockCount(config, async (ctx) => {
  // Fetch current inventory
  const items = await ctx.models.stockItem.findMany({
    where: { location: { id: ctx.params.locationId } },
  });
 
  // Display data grid for count entry
  const result = await ctx.ui.page({
    title: "Enter stock counts",
    content: [
      ctx.ui.display.header({ title: "Count all items at this location" }),
      ctx.ui.inputs.dataGrid("counts", {
        data: items.map((item) => ({
          id: item.id,
          sku: item.sku,
          name: item.name,
          expectedQty: item.quantity,
          countedQty: item.quantity,
        })),
        columns: [
          { key: "sku", label: "SKU", type: "text", editable: false },
          { key: "name", label: "Name", type: "text", editable: false },
          { key: "expectedQty", label: "Expected", type: "number", editable: false },
          { key: "countedQty", label: "Counted", type: "number", editable: true },
        ],
        validate: (data) => {
          const hasNegative = data.some((row) => row.countedQty < 0);
          if (hasNegative) {
            return "Counted quantities cannot be negative";
          }
          return true;
        },
      }),
    ],
  });
 
  // Process variances
  for (const count of result.counts) {
    if (count.countedQty !== count.expectedQty) {
      await ctx.models.stockVariance.create({
        stockItemId: count.id,
        expected: count.expectedQty,
        counted: count.countedQty,
        variance: count.countedQty - count.expectedQty,
      });
    }
  }
 
  return { message: "Stock count recorded" };
});