Skip to main content
Big news! Interval has been acquired by Meter. Learn more →

io.display.table

Displays tabular data.

Usage

await io.display.table("Albums", {
helpText: "Includes the artist and its year of release.",
data: [
{
album: "Exile on Main Street",
artist: "The Rolling Stones",
year: 1972,
},
{
album: "Thriller",
artist: "Michael Jackson",
year: 1982,
},
{
album: "Enter the Wu-Tang (36 Chambers)",
artist: "Wu-Tang Clan",
year: 1993,
},
],
});
interval.com

Props

columns

Optional

(string | object)[]

Optional array of column definitions. If not provided, the object keys will be used.

accessorKey

Optional

string

Column accessor key. At least one of `accessorKey` or `renderCell` must be specified.

label

Required

string

Column header label.

renderCell

Optional

(row: T) => string | object

Function that receives the row as argument and returns either a string value, or an object for advanced customization. See the function definition below for available properties. At least one of `renderCell` or `accessorKey` must be specified.
renderCell: (row: T) => string | {
// the visible cell label
label?: string | number | boolean | Date;
// an optional underlying value for sorting
value?: string | number | boolean | Date;
// links the cell's contents to an external URL
url?: string;
// links the cell's contents to another action or page
route?: string;
// arbitrary key/value pairs to send to the linked route
params?: Record<string, any>;
// highlight color for the cell, will affect text color and background color
highlightColor?: "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "pink" | "gray"
// a visible image to be displayed in the cell
// must contain either `url` or `buffer`
image?: {
// a URL to the image
url?: string
// a buffer containing the image contents
buffer?: Buffer
// the image alt tag
alt?: string
// the size of the image
size?: "thumbnail" | "small" | "medium" | "large"
}
}

data

Optional

T[]

Array of table data. Values are objects with arbitrary keys/values. Must be specified if `getData` is not specified.

defaultPageSize

Optional

number

The default page size for the paginated table. Pass `Infinity` to disable pagination by default.

getData

Optional

(state) => Promise<{ data: T[], totalRecords?: number }>

An async function that receives the current table state as its only argument, and returns an array of `data` and an optional `totalRecords` count.
getData: (
state: {
// the user's search query, may be an empty string
queryTerm?: string,
// the `accessorKey` for the column being sorted
sortColumn?: string,
// the sort direction for the column being sorted
sortDirection?: "asc" | "desc",
// the number of records to skip for the current
// `queryTerm` and sorting, for pagination
offset: number,
// the current page size, should return at least this
// many records if available
pageSize: number
}
}) => Promise<{
// the underlying data records for this table state
data: T[],
// the total number of records available for this queryTerm, if known
totalRecords?: number
}>

helpText

Optional

string

Secondary label providing an additional description of the data. Supports inline markdown elements like bold, italics, and links.

isFilterable

Optional

boolean

Whether to show a text input alongside the table for filtering and searching through rows. Interval automatically handles filtering when using `data`, but you must implement filtering yourself when using `getData`. See the 'Asynchronous data' example for a code sample. Defaults to `true`.

isSortable

Optional

boolean

Whether to allow sorting by column. Interval automatically handles sorting when using `data`, but you must implement sorting yourself when using `getData`. See the 'Asynchronous data' example for a code sample. Defaults to `true`.

orientation

Optional

'horizontal' | 'vertical'

Whether to render the table with records displayed as rows ("horizontal") or columns ("vertical"). Defaults to "horizontal".

rowMenuItems

Optional

(row: T) => object[]

Optional array of items to display in a dropdown menu next to each row. Available beginning with SDK v0.25.0.
rowMenuItems: (row: T) => {
// the visible menu item label
label: string;
// links the menu item to an external URL
url?: string;
// links the menu item to another action or page
route?: string;
// arbitrary key/value pairs to send to the linked route
params?: Record<string, any>;
// disables the menu item
disabled?: boolean
// the style of the item
theme?: 'danger' | undefined
}[]

Returns

null

Examples

Customizing output

The default behavior shown above is to display all of the fields in each record. To customize the display of records, a columns property can be provided. The columns property can contain an array of data property names, and only those columns will be displayed in the table.

await io.display.table("Users", {
data: [
{
email: "carsta.rocha@example.com",
phone_number: "(60) 1416-4953",
birthdate: "1993-08-04",
first_name: "Carsta",
last_name: "Rocha",
image: "https://example.com/photos/21351234.jpg",
website_url: "https://example.com",
},
{
email: "irene.morales@example.org",
phone_number: "625-790-958",
birthdate: "1982-04-28",
first_name: "Irene",
last_name: "Morales",
image: "https://example.com/photos/8321527.jpg",
website_url: "https://example.org",
},
],
columns: ["first_name", "last_name", "email"],
});

The columns array can also contain definition objects, with a label property and either an accessorKey string or a renderCell callback that returns a primitive value or object with optional label and value primitive values, url or route, and highlightColor string properties, and optional params and image object properties.

info

The label property (and plain string return value shorthand) supports markdown, though markdown links will be omitted if used alongside the route or url properties.

await io.display.table("Users", {
data: [
{
email: "carsta.rocha@example.com",
phone_number: "(60) 1416-4953",
birthdate: "1993-08-04",
first_name: "Carsta",
last_name: "Rocha",
image: "https://example.com/photos/21351234.jpg",
website_url: "https://example.com",
},
{
email: "irene.morales@example.org",
phone_number: "625-790-958",
birthdate: "1982-04-28",
first_name: "Irene",
last_name: "Morales",
image: "https://example.com/photos/8321527.jpg",
website_url: "https://example.org",
},
],
columns: [
{
label: "Name",
renderCell: row => `${row.first_name} ${row.last_name}`,
},
{
label: "Phone number",
accessorKey: "phone_number",
},
{
label: "Photo",
renderCell: row => ({
image: {
url: row.image,
alt: `${row.first_name} ${row.last_name} profile photo`,
size: "small",
},
}),
},
{
label: "Birth date",
renderCell: row => {
const [y, m, d] = row.birthdate.split("-").map(s => Number(s));
const birthDate = new Date(y, m - 1, d);
return {
label: birthDate.toLocaleDateString(),
value: birthDate,
};
},
},
{
label: "Website",
renderCell: row => ({
label: row.website_url,
url: row.website_url,
}),
},
{
label: "Edit action",
renderCell: row => ({
label: "Edit user",
route: "edit_user",
params: {
email: row.email,
},
}),
},
],
});

If the renderCell callback returns a route property, a link will be generated to the action with that slug. A params object can also be defined, this object will be passed to the action's ctx.params context value.

tip

The two forms shown above can be combined within a single columns definition.

Adding menus

Beginning with SDK v0.25.0, each row can be given a dropdown menu using the rowMenuItems property, a function that provides the current row as the only argument and returns an array of menu items. Menu items are typically links to other actions.

await io.display.table("Albums", {
data: albums,
columns: ["album", "artist", "year"],
rowMenuItems: row => [
{
label: "Edit metadata",
route: "edit_album",
params: { id: row.id },
},
{
label: "Listen on Spotify",
// external URLs automatically open in a new tab.
url: `https://open.spotify.com/album/${row.spotifyId}`,
},
],
});
interval.com

Asynchronous data

tip

Best for situations like:

  • Large data sets of thousands of records
  • Fetching additional data from external APIs like Stripe

Beginning with SDK v0.31.0, data can be fetched asynchronously depending on user input like searching and page navigation. This allows advanced use cases like displaying large or external data sets without needing to fetch the entire set of records up front.

Fetching data asynchronously prevents many user experience enhancements like caching table data, client-side searching and pagination, and requires more code to account for search, sorting, and pagination. When in doubt, start with the synchronous data property and use this when the situation requires it.

info

Columns in async data tables are only available for sorting if the column is statically defined with an accessorKey property or via string shorthand.

await io.display.table("Purchases", {
getData: async ({
queryTerm,
sortColumn,
sortDirection,
offset,
pageSize,
}) => {
const where = {
OR: [
{
buyer: {
name: {
search: queryTerm,
},
},
},
{
product: {
name: {
search: queryTerm,
},
},
},
],
};

let orderBy;

if (sortColumn && sortDirection) {
orderBy = {
[sortColumn]: sortDirection,
};
}

const data = await db.purchases.find({
where,
skip: offset,
limit: pageSize,
orderBy,
});

const totalRecords = await db.purchases.count({
where,
});

return {
data,
totalRecords,
};
},
columns: [
{
label: "Buyer",
renderCell: row => row.buyer.name,
},
{
label: "Product",
renderCell: row => row.product.name,
},
{
label: "Amount",
accessorKey: "totalPrice",
},
],
});
danger

Be sure to safely sanitize, escape, or prepare all values before passing them to any database queries.

Cursor pagination

If you need to load data asynchronously and your backend uses cursor pagination, you can use the following class to adapt the properties provided by Interval's getData() handler to support cursor pagination.

cursor-pagination.ts
import { T_IO_STATE } from "@interval/sdk/dist/ioSchema";

export default class CursorPagination {
cursors: string[] = [];
lastOffset: number = 0;
lastQueryTerm: string | undefined;
lastSortColumn: string | undefined;
lastSortDirection: string | undefined;

update(newState: T_IO_STATE<"DISPLAY_TABLE">) {
let direction = newState.offset >= this.lastOffset ? "forward" : "backward";

// Interval resets to page 0 when any of these change
if (
newState.queryTerm !== this.lastQueryTerm ||
newState.sortColumn !== this.lastSortColumn ||
newState.sortDirection !== this.lastSortDirection
) {
this.cursors.length = 0;
this.lastOffset = 0;
direction = "forward";
}

if (direction === "backward") {
this.cursors.pop();
this.cursors.pop();
}

this.lastOffset = newState.offset;
this.lastQueryTerm = newState.queryTerm;
this.lastSortColumn = newState.sortColumn;
this.lastSortDirection = newState.sortDirection;
}

get lastCursor() {
return this.cursors[this.cursors.length - 1] ?? null;
}

add(id?: string | null) {
if (id) {
this.cursors.push(id);
}
}
}

The following usage example uses Prisma as the database client, but the same approach can be used with any database client that supports cursor pagination.

Important to note:

  1. The CursorPagination class must be initialized before calling io.display.table().
  2. The update() method must be called with the new state at the top of the getData() handler.
  3. You can reference lastCursor to get the cursor value to pass to your database query.
  4. The add() method must be called with the ID of the last record returned by the getData() handler.
usage.ts
import { io, Layout, Page } from "@interval/sdk";
import prisma from "~/prisma.server";
import CursorPagination from "./cursor-pagination";

export default new Page({
name: "Tags",
handler: async () => {
// 1️⃣ Initialize the CursorPagination class
const cursor = new CursorPagination();

return new Layout({
title: "Tags",
children: [
io.display.table("", {
defaultPageSize: 30,
columns: ["name", "slug"],
getData: async newState => {
const { sortColumn, sortDirection, pageSize, queryTerm } = newState;

// 2️⃣ Update with the new state
cursor.update(newState);

const where = queryTerm
? {
name: { contains: queryTerm },
}
: undefined;

let orderBy;

if (sortColumn && sortDirection) {
orderBy = {
[sortColumn]: sortDirection,
};
}

// 3️⃣ Use the previous cursor in your query
const lastResultId = cursor.lastCursor;

const data = await prisma.tag.findMany({
where,
cursor: lastResultId ? { id: lastResultId } : undefined,
skip: lastResultId ? 1 : 0, // skip the cursor
take: pageSize,
orderBy,
});

const totalRecords = await prisma.tag.count({
where,
});

// 4️⃣ Store the cursor for the data you're returning
cursor.add(data[data.length - 1]?.id);

return {
data,
totalRecords,
};
},
}),
],
});
},
});
Was this section useful?