io.display.table
Displays tabular data.
Usage
- TypeScript
- JavaScript
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,
},
],
});
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,
},
],
});
Props
- TypeScript
- JavaScript
columns
Optional
(string | object)[]
accessorKey
Optional
string
label
Required
string
renderCell
Optional
(row: T) => string | object
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[]
defaultPageSize
Optional
number
getData
Optional
(state) => Promise<{ data: T[], totalRecords?: number }>
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
isFilterable
Optional
boolean
isSortable
Optional
boolean
orientation
Optional
'horizontal' | 'vertical'
rowMenuItems
Optional
(row: T) => object[]
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
columns
Optional
(string | object)[]
accessorKey
Optional
string
label
Required
string
renderCell
Optional
(row: T) => string | object
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[]
defaultPageSize
Optional
number
getData
Optional
(state) => Promise<{ data: T[], totalRecords?: number }>
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
isFilterable
Optional
boolean
isSortable
Optional
boolean
orientation
Optional
'horizontal' | 'vertical'
rowMenuItems
Optional
(row: T) => object[]
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.
- TypeScript
- JavaScript
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"],
});
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.
- TypeScript
- JavaScript
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,
},
}),
},
],
});
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.
- TypeScript
- JavaScript
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}`,
},
],
});
await io.display.table("Albums", {
data: albums,
columns: ["album", "artist", "year"],
rowMenuItems: row => [
{
label: "Edit metadata",
routes: "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}`,
},
],
});
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.
- TypeScript
- JavaScript
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",
},
],
});
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.
- TypeScript
- JavaScript
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:
- The
CursorPagination
class must be initialized before callingio.display.table()
. - The
update()
method must be called with the new state at the top of thegetData()
handler. - You can reference
lastCursor
to get the cursor value to pass to your database query. - The
add()
method must be called with the ID of the last record returned by thegetData()
handler.
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,
};
},
}),
],
});
},
});
export default class CursorPagination {
cursors = [];
lastOffset = 0;
lastQueryTerm;
lastSortColumn;
lastSortDirection;
update(newState) {
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) {
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:
- The
CursorPagination
class must be initialized before callingio.display.table()
. - The
update()
method must be called with the new state at the top of thegetData()
handler. - You can reference
lastCursor
to get the cursor value to pass to your database query. - The
add()
method must be called with the ID of the last record returned by thegetData()
handler.
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,
};
},
}),
],
});
},
});