Search documentation

Search for a page or heading...

tablecn
0

One orders table with every feature on, and all of its code.

Try it: search da nang, filter by Pickup between two times, Created at between two moments or City is any of two cities, sort by several columns with Shift, then select a few rows and Mark as shipped or Delete them. Double-click a customer, status, city, shipped or amount to edit it: the city searches a fake API, and a negative amount is refused. Reload: the search, filter, sort and page come back from the URL, and the column layout from your browser.

What's in it

  • Search in the order id, customer, email and city, ignoring accents.
  • A filter for each field type: text, select, multi-select, number, yes/no, date, time of day, and date with time.
  • Sorting, newest orders first by default, and pages of 10 to 100 rows.
  • Row selection with bulk actions. Refunded orders can't be selected.
  • Inline editing of the customer, status, city, shipped and amount, saved after a fake 400 ms request that refuses negative amounts. The city's options come from a search in a fake API.
  • The order id pinned to the start, the amount to the end, and the email hidden until turned on in Columns.
  • Columns users can move, resize and color, saved in localStorage.
  • A reload button with the loading state.

The code

Install the blocks first, as in Installation. The files below then work as they are.

Fields and data

The fields decide what the filter offers. The rows are made up here; yours come from your API or database.

components/orders-data.ts
import type { FieldDefinition } from "@querycn/filter-core"

export interface Order {
  id: string
  customer: string
  email: string
  status: "paid" | "pending" | "refunded"
  city: string
  amount: number
  shipped: boolean
  deliveryDate: string
  pickup: string
  createdAt: string
}

const CITIES = ["Hà Nội", "Hồ Chí Minh", "Đà Nẵng", "Singapore", "Bangkok"]
export const cityOptions = CITIES.map((city) => ({ label: city, value: city }))

// One field per filter type: text, select, multiSelect, number, boolean, date, time, datetime.
export const orderFields: FieldDefinition[] = [
  { name: "customer", label: "Customer", type: "text" },
  {
    name: "status",
    label: "Status",
    type: "select",
    options: [
      { label: "Paid", value: "paid" },
      { label: "Pending", value: "pending" },
      { label: "Refunded", value: "refunded" },
    ],
  },
  {
    name: "city",
    label: "City",
    type: "multiSelect",
    options: cityOptions,
  },
  { name: "amount", label: "Amount", type: "number", defaultOperator: "gte" },
  { name: "shipped", label: "Shipped", type: "boolean" },
  { name: "deliveryDate", label: "Delivery", type: "date" },
  { name: "pickup", label: "Pickup", type: "time" },
  { name: "createdAt", label: "Created at", type: "datetime" },
]

const FIRST =
  "Nguyễn|Olivia|Trần|Jackson|Đặng|Isabella|Lê|Noah|Phạm|Emma".split("|")
const LAST =
  "Văn An|Martin|Thị Bình|Lee|Minh Châu|Nguyen|Hoàng|Smith|Quốc Huy|Brown".split(
    "|"
  )
const STATUSES = ["paid", "pending", "paid", "refunded", "paid"] as const

const pad = (part: number) => String(part).padStart(2, "0")
const day = (offset: number) =>
  new Date(Date.UTC(2026, 0, 1 + offset)).toISOString().slice(0, 10)

// Made-up but stable, so the server and the browser render the same rows.
// Times have no zone, so every visitor sees and filters the same hours.
export const ORDERS: Order[] = Array.from({ length: 500 }, (_, index) => {
  const created = (index * 37) % 270
  const status = STATUSES[(index * 3) % 5]!
  return {
    id: `ORD-${1001 + index}`,
    customer: `${FIRST[index % 10]} ${LAST[(index * 7) % 10]}`,
    email: `customer${1001 + index}@example.com`,
    status,
    city: CITIES[(index * 11) % 5]!,
    amount: ((index * 7919) % 99000) / 100 + 5,
    shipped: status === "paid" && index % 3 !== 0,
    deliveryDate: day(created + 2 + (index % 5)),
    pickup: `${pad(8 + ((index * 5) % 13))}:${pad(((index * 7) % 4) * 15)}`,
    createdAt: `${day(created)}T${pad((index * 5) % 24)}:${pad((index * 17) % 60)}`,
  }
})

The table

components/orders-table.tsx
"use client"

import * as React from "react"
import { filterOptions, parseDateOnly } from "@querycn/filter-core"
import { NextFilterProvider } from "@querycn/filter-next"
import {
  createDataTableColumnHelper,
  resetPagePatch,
  useDataTable,
} from "@querycn/table-react"
import { CheckIcon, Trash2Icon, TruckIcon } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DataTable } from "@/components/data-table/data-table"
import { DataTablePagination } from "@/components/data-table/data-table-pagination"
import { DataTableSearch } from "@/components/data-table/data-table-search"
import { createSelectionColumn } from "@/components/data-table/data-table-selection-column"
import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
import { FilterBuilder } from "@/components/filter/filter-builder"
import { FilterChips } from "@/components/filter/filter-chips"

import { cityOptions, ORDERS, orderFields, type Order } from "./orders-data"

const statusVariant = {
  paid: "default",
  pending: "secondary",
  refunded: "outline",
} as const

const money = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

// A fixed locale, so the server and the browser print the same text.
const dayFormat = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
})

// `parseDateOnly` reads the day in local time; `new Date("2026-03-05")` would be UTC.
const formatDay = (day: string) => {
  const date = parseDateOnly(day)
  return date ? dayFormat.format(date) : day
}

const helper = createDataTableColumnHelper<Order>()

// Stands in for your API: the cities matching what the user types.
async function loadCities(search: string, signal: AbortSignal) {
  await new Promise((resolve) => setTimeout(resolve, 300))
  signal.throwIfAborted()
  return [...filterOptions(cityOptions, search)]
}

const statusOptions = [
  { label: "Paid", value: "paid" },
  { label: "Pending", value: "pending" },
  { label: "Refunded", value: "refunded" },
]

const columns = [
  createSelectionColumn<Order>(),
  helper.accessor("id", {
    header: "Order",
    size: 110,
    meta: { defaultPinned: "start" },
  }),
  helper.accessor("customer", {
    header: "Customer",
    size: 180,
    meta: { edit: { type: "text" } },
  }),
  helper.accessor("email", {
    header: "Email",
    size: 230,
    meta: { defaultHidden: true },
  }),
  helper.accessor("status", {
    header: "Status",
    size: 110,
    meta: { edit: { type: "select", options: statusOptions } },
    cell: ({ getValue }) => {
      const status = getValue<Order["status"]>()
      return (
        <Badge variant={statusVariant[status]} className="capitalize">
          {status}
        </Badge>
      )
    },
  }),
  helper.accessor("city", {
    header: "City",
    size: 140,
    meta: { edit: { type: "select", loadOptions: loadCities } },
  }),
  helper.accessor("shipped", {
    header: "Shipped",
    size: 100,
    meta: { edit: { type: "boolean" } },
    cell: ({ getValue }) =>
      getValue<boolean>() ? (
        <CheckIcon aria-label="Shipped" className="size-4" />
      ) : (
        <span className="text-muted-foreground">No</span>
      ),
  }),
  helper.accessor("deliveryDate", {
    header: "Delivery",
    size: 120,
    cell: ({ getValue }) => (
      <span className="tabular-nums">{formatDay(getValue<string>())}</span>
    ),
  }),
  helper.accessor("pickup", {
    header: "Pickup",
    size: 100,
    cell: ({ getValue }) => (
      <span className="tabular-nums">{getValue<string>()}</span>
    ),
  }),
  helper.accessor("createdAt", {
    header: "Created at",
    size: 160,
    cell: ({ getValue }) => {
      const [day, time] = getValue<string>().split("T")
      return (
        <span className="tabular-nums">
          {formatDay(day!)} {time}
        </span>
      )
    },
  }),
  helper.accessor("amount", {
    header: "Amount",
    size: 120,
    sortDescFirst: true,
    meta: { defaultPinned: "end", edit: { type: "number" } },
    cell: ({ getValue }) => (
      <span className="tabular-nums">{money.format(getValue<number>())}</span>
    ),
  }),
]

const searchColumns = ["id", "customer", "email", "city"]

function OrdersTable() {
  const [orders, setOrders] = React.useState(ORDERS)
  const [isRefreshing, setIsRefreshing] = React.useState(false)
  const table = useDataTable({
    data: orders,
    columns,
    getRowId: (order) => order.id,
    storageKey: "tablecn-full-example",
    searchColumns,
    url: {
      pageSizes: [10, 20, 50, 100],
      defaultSorting: [{ id: "createdAt", desc: true }],
    },
    enableRowSelection: (row) => row.original.status !== "refunded",
    // Stands in for saving to your API: throw to keep the editor open with a message.
    onCellEdit: async ({ rowId, columnId, value }) => {
      await new Promise((resolve) => setTimeout(resolve, 400))
      if (columnId === "amount" && (value === null || (value as number) < 0)) {
        throw new Error("Enter an amount of 0 or more.")
      }
      setOrders((current) =>
        current.map((order) =>
          order.id === rowId ? { ...order, [columnId]: value } : order
        )
      )
    },
  })

  const update = (ids: string[], change: (order: Order) => Order | null) => {
    const selected = new Set(ids)
    setOrders((current) =>
      current.flatMap((order) => {
        if (!selected.has(order.id)) return [order]
        const next = change(order)
        return next ? [next] : []
      })
    )
    table.resetRowSelection(true)
  }

  return (
    <>
      <DataTableToolbar
        table={table}
        // Stands in for refetching from your API.
        onRefresh={() => {
          setIsRefreshing(true)
          setTimeout(() => setIsRefreshing(false), 800)
        }}
        isRefreshing={isRefreshing}
        selectionActions={(rows) => {
          const ids = rows.map((row) => row.id)
          return (
            <>
              <Button
                variant="outline"
                size="sm"
                onClick={() =>
                  update(ids, (order) => ({ ...order, shipped: true }))
                }
              >
                <TruckIcon />
                Mark as shipped
              </Button>
              <Button
                variant="outline"
                size="sm"
                onClick={() => update(ids, () => null)}
              >
                <Trash2Icon />
                Delete
              </Button>
            </>
          )
        }}
      >
        <DataTableSearch table={table} placeholder="Search orders…" />
        <FilterBuilder />
        <FilterChips />
      </DataTableToolbar>
      <DataTable
        table={table}
        isLoading={isRefreshing}
        className="max-h-[480px]"
      />
      <DataTablePagination table={table} />
    </>
  )
}

/** Search, filter, sort and page in the URL; needs a Suspense boundary above it. */
export function OrdersPage() {
  return (
    <NextFilterProvider
      fields={orderFields}
      shallow
      onApply={() => resetPagePatch()}
    >
      <OrdersTable />
    </NextFilterProvider>
  )
}

The page

NextFilterProvider reads the URL with useSearchParams, so the page needs a Suspense boundary:

app/orders/page.tsx
import { Suspense } from "react"

import { OrdersPage } from "@/components/orders-table"

export default function Page() {
  return (
    <Suspense>
      <OrdersPage />
    </Suspense>
  )
}

The filtering, sorting and paging here run in the browser, over every row. For a large table, let your backend do it: see Server data.