Skip to content

Overviews

When an entry has children, or a root holds entries, the dashboard lists them in an overview: a table, or cards, with the title of each entry and, when they tell the entries apart, its type, status and who edited it last. The overview option of a container type or a root adds your own columns, a default order, a card image and toolbar actions.

The products overview, a table with a thumbnail, price, material and stock for every product, sorted by priceThe products overview, a table with a thumbnail, price, material and stock for every product, sorted by price
The products overview of the Oak & Loom demo, with its own columns for price, material and stock, sorted by price.
schema/Blog.ts
import {Config, Field, Query} from 'alinea'

export const BlogPost = Config.document('Blog post', {
  fields: {
    cover: Field.image('Cover'),
    category: Field.entry('Category', {condition: {_type: 'Category'}}),
    publishDate: Field.date('Publish date')
  }
})

export const Blog = Config.document('Blog', {
  contains: ['BlogPost'],
  fields: {},
  overview: {
    columns: {
      cover: Config.column({
        header: 'Cover',
        width: 72,
        select: BlogPost.cover
      }),
      category: Config.column({
        header: 'Category',
        select: BlogPost.category,
        sortBy: BlogPost.category.first({select: Query.title})
      }),
      publishDate: Config.column({
        header: 'Published',
        width: 140,
        select: BlogPost.publishDate
      })
    },
    sort: {desc: BlogPost.publishDate},
    layout: 'cards',
    thumbnail: BlogPost.cover
  }
})

The overview of a type applies to the children of every entry of that type, here the posts of each Blog. On a root it applies to the entries at the top level of the root:

cms.ts
import {Config} from 'alinea'
import {productsOverview} from '@/schema/Product'

export const products = Config.root('Products', {
  contains: ['Product'],
  overview: productsOverview
})

Options

  • columns: the columns shown after the title and the built-in columns, keyed by name. Create them with Config.column, see Columns. A column with position: 'start' goes before the built-in columns.

  • builtins: show or hide the built-in columns, for example {updated: false}.

  • sort: the default order of the children, in the overview and in the sidebar tree. See Sorting.

  • sorts: the orders editors can pick in the filter and sort menu, keyed by name. See Sort options.

  • filters: filters editors can apply in the filter and sort menu, keyed by name. See Filters.

  • layout: 'table' or 'cards', the layout the overview opens in. Editors can still switch.

  • thumbnail: the image shown on cards: an image field, or one per type ({BlogPost: BlogPost.cover, Event: Event.poster}). Defaults to the first image found in the fields of the entry.

  • actions: components rendered in the toolbar of the overview, such as an export button. See Actions.

Columns

Config.column(options) takes:

  • header (required): the column header.

  • select: what the column shows. Anything a query can select: a field (Product.price), an expression (Query.url), a query of linked entries, or an object of those.

  • format: a function that turns the selected value into text: (value, {locale}) => string.

  • view: a React component that renders the cell, see Custom cells.

  • sortBy, sortable: how the column sorts, see Sorting.

  • width: a width in pixels (120) or a fraction of the remaining space ('2fr'). The default is '1fr'.

  • minWidth: the minimum width in pixels of a fractional column, 120 by default.

  • align: 'start', 'end' or 'center'. Use 'end' for numbers and amounts.

  • collapsible: hide the column on narrow screens. true by default, set it to false for columns that should always be visible.

  • position: 'start' places the column right after the title, before the built-in columns, for example a thumbnail or an article number. 'end', the default, places it after them. Columns keep the order you define them in within each position.

The value passed to format and view is what a cms.find with the same select returns. Without format or view, a column that selects a field renders its value like the dashboard does elsewhere: an image as a thumbnail, a link to an entry as that entry's title. Linked entries open when clicked, and so do selected values with an entryId or _entry (or an id and a title).

A catalogue of products, with linked categories and brands and a price per locale:

schema/Product.ts
import {Config, Field, type OverviewOptions, Query} from 'alinea'

export const Product = Config.document('Product', {
  fields: {
    articleNumber: Field.text('Article number'),
    categories: Field.entry.multiple('Categories', {
      condition: {_type: 'Category'}
    }),
    brand: Field.entry('Brand', {condition: {_type: 'Brand'}}),
    price: Field.number('Price'),
    stock: Field.number('Stock')
  }
})

export const productsOverview: OverviewOptions = {
  columns: {
    articleNumber: Config.column({
      header: 'Article number',
      width: 140,
      select: Product.articleNumber,
      position: 'start'
    }),
    categories: Config.column({
      header: 'Categories',
      select: Product.categories.find({
        select: {entryId: Query.id, title: Query.title},
        filter: {_status: 'published'},
        orderBy: {asc: Query.title}
      })
    }),
    brand: Config.column({
      header: 'Brand',
      select: Product.brand,
      sortBy: Product.brand.first({select: Query.title})
    }),
    price: Config.column({
      header: 'Price',
      width: 120,
      align: 'end',
      select: Product.price,
      format: (price, {locale}) =>
        typeof price === 'number'
          ? new Intl.NumberFormat(locale ?? 'en', {
              style: 'currency',
              currency: 'EUR'
            }).format(price)
          : ''
    }),
    stock: Config.column({
      header: 'Stock',
      width: 120,
      select: Product.stock,
      view: '@/views/StockCell#StockCell'
    })
  },
  builtins: {updated: false},
  actions: ['@/views/ExportProducts#ExportProducts']
}
  • articleNumber comes right after the title, before the built-in columns, because of position: 'start'.

  • categories selects the published categories of each product, sorted by title. They are rendered as links because they select an entryId and a title.

  • brand selects the entry field, so it shows the title of the brand. Linked entries don't sort by themselves: sortBy sorts the column by the title of the brand.

  • price is formatted as an amount in euro, in the locale of the listed entry.

Custom cells

view renders the cell with your own component. It receives OverviewCellProps: the selected value, the entry of the row (with its id, type, title, url, status, locale, workspace, root and parentId), the column key and the locale. Build it with alinea/components:

views/StockCell.tsx
import type {OverviewCellProps} from 'alinea/cms'
import {Badge} from 'alinea/components'

export function StockCell({value}: OverviewCellProps<number | null>) {
  if (typeof value !== 'number') return null
  return <Badge>{value > 0 ? `${value} in stock` : 'Sold out'}</Badge>
}

Like other views, view takes a component or a path to one. Use a path to keep dashboard code out of your site, see Point to the view. format is a plain function, so it can live in your schema.

Built-in columns

The title is always the first column. The built-in columns follow, then the columns you define. Columns with position: 'start' go between the title and the built-in columns.

A built-in column only shows when it tells the listed entries apart. The dashboard decides this from all children of the parent, not only those on screen, so the columns don't change while scrolling or sorting:

  • type: the type of the entry. Shown when the children have more than one type, and in search results and filtered lists.

  • status: the publication status. Shown when the children differ in status, for example when some are drafts. A list of published entries has no status column.

  • updated and author: when the entry was last edited and by whom, read from the audit fields of the metadata field (under the key metadata, which Config.document and Field.metadata() add). Shown when at least one child stores that audit data, so lists of entries created before audit data was recorded don't show empty columns.

The columns follow the types of the children that are there. A parent without contains accepts any type, but its overview only shows the columns of the types it holds.

Force them on or off with builtins, for example {status: true} to always show the status, or {type: false, updated: false, author: false} to never show those. A column keyed type, status, updated or author replaces that built-in column in place, for example to show the writer of an article instead of the last editor. The title key is reserved.

Sorting

Editors sort an overview by clicking a column header, or from the filter and sort menu. The title, the built-in columns, expressions of the entry (such as Query.title) and fields that hold a single value (text, number, date, select, check, path, ...) are sortable. Columns that select links, lists, multiple selects, rich text or JSON need sortBy: an expression, or a query of a linked entry that selects a single expression, such as Product.brand.first({select: Query.title}). Set sortable: false to turn sorting off for a column.

Sorting runs in the database query, and entries without a value come last. It only changes what the editor sees, never the stored order of the entries. While a list is sorted, "Sorted by Price · Reset" shows in the toolbar and rows can't be reordered; Reset goes back to the default order. The sort is kept in the url (?sort=price, or ?sort=-price for descending), so it survives a reload and can be shared, and it is restored when the editor comes back to the overview in the same session. Search results stay ordered by relevance.

Default order

sort sets the order of the children in the overview and in the sidebar tree. It takes an orderBy or an array of them, such as [{desc: Event.date}, {asc: Query.title}]. Children of a parent with a sort can't be reordered by hand. find_entries of the MCP server returns them in the same order.

Without sort, children keep the order editors give them: drag rows in the table or cards, or entries in the sidebar tree, to reorder them. Like insertOrder, sort only affects the dashboard: queries return children in their stored order unless you pass an orderBy.

sort replaces orderChildrenBy, which still works but is deprecated.

Sort options

The filter and sort menu lists the title and the sortable columns. Declare sorts to list your own orders instead. Each takes a label, by: a value like a column's sortBy or an array of them, where later values order the entries that share the earlier ones, and direction: 'asc' (the default) or 'desc'. Picking an option again reverses it.

schema/Events.ts
import {Config, Query} from 'alinea'
import {Event} from './Event'

export const Events = Config.document('Events', {
  contains: ['Event'],
  overview: {
    sort: {asc: Event.date},
    sorts: {
      date: {label: 'Date', by: Event.date},
      title: {label: 'Title', by: Query.title},
      venue: {label: 'Venue', by: [Event.city, Event.venue]}
    }
  },
  fields: {}
})

The key of an option is kept in the url (?sort=venue). An option keyed like a column also orders that column when its header is clicked, other columns stay sortable by their header.

Filters

filters adds filters to the filter and sort menu. Each has a label and options, keyed by name, with a label and a filter: a query filter on the fields of the listed entries. Editors pick one option of a filter, or several with multiple: true: entries then match any of them. Entries match every filter that is applied, and the search terms. Rows can't be reordered while a filter applies.

schema/Products.ts
import {Config} from 'alinea'

export const Products = Config.document('Products', {
  contains: ['Product'],
  overview: {
    filters: {
      availability: {
        label: 'Availability',
        options: {
          inStock: {label: 'In stock', filter: {stock: {gt: 0}}},
          soldOut: {label: 'Sold out', filter: {stock: 0}}
        }
      },
      price: {
        label: 'Price',
        multiple: true,
        options: {
          low: {label: 'Under €50', filter: {price: {lt: 50}}},
          mid: {label: '€50 to €200', filter: {price: {gte: 50, lt: 200}}},
          high: {label: '€200 and up', filter: {price: {gte: 200}}}
        }
      }
    }
  },
  fields: {}
})

Mixed lists

When a parent contains several types, a column can select a different value per type. Key select and sortBy by the type names of your schema:

schema/News.ts
import {Config, Field, Query} from 'alinea'

export const Article = Config.document('Article', {
  fields: {
    author: Field.entry('Author', {condition: {_type: 'Person'}}),
    publishDate: Field.date('Publish date')
  }
})

export const Event = Config.document('Event', {
  fields: {
    organiser: Field.entry('Organiser', {condition: {_type: 'Person'}}),
    startDate: Field.date('Start date')
  }
})

export const News = Config.document('News', {
  contains: ['Article', 'Event'],
  fields: {},
  overview: {
    columns: {
      person: Config.column({
        header: 'Author or organiser',
        select: {Article: Article.author, Event: Event.organiser},
        sortBy: {
          Article: Article.author.first({select: Query.title}),
          Event: Event.organiser.first({select: Query.title})
        }
      }),
      date: Config.column({
        header: 'Date',
        width: 140,
        select: {Article: Article.publishDate, Event: Event.startDate}
      })
    }
  }
})

Entries of a type without a value in the column show "–" and come last when sorted.

Actions

actions renders components in the toolbar of the overview. They receive OverviewActionProps: the workspace, root and parentId of the list, the locale, the search terms, the sort the editor picked and a query for the listed entries in their current order, without paging. Pass the query to useGraph().find with your own select, for example to export the products above as CSV:

views/ExportProducts.tsx
import {Query} from 'alinea'
import {type OverviewActionProps, useGraph} from 'alinea/cms'
import {Button} from 'alinea/components'
import {Product} from '@/schema/Product'

export function ExportProducts({query}: OverviewActionProps) {
  const graph = useGraph()
  async function exportCsv() {
    const rows = await graph.find({
      ...query,
      select: {
        title: Query.title,
        articleNumber: Product.articleNumber,
        price: Product.price
      }
    })
    const csv = rows
      .map(row => [row.title, row.articleNumber, row.price].join(';'))
      .join('\n')
    const link = document.createElement('a')
    link.href = URL.createObjectURL(new Blob([csv], {type: 'text/csv'}))
    link.download = 'products.csv'
    link.click()
  }
  return (
    <Button variant="outline" onClick={exportCsv}>
      Export
    </Button>
  )
}

Entry tables in your own views

EntryTable from alinea/cms renders a table of entries with the columns of an overview: the headers sort and a row opens its entry. Use it in a Field.view section, or any other custom view, to show related entries. Here a brand lists its products:

schema/Brand.ts
import {Config, Field} from 'alinea'

export const Brand = Config.document('Brand', {
  fields: {
    logo: Field.image('Logo'),
    ...Field.view('@/views/BrandProducts#BrandProducts')
  }
})
views/BrandProducts.tsx
import {EntryTable, useEntry} from 'alinea/cms'
import {Field} from 'alinea/components'
import {Product, productsOverview} from '@/schema/Product'

export function BrandProducts() {
  const entry = useEntry()
  if (!entry) return null
  return (
    <Field label="Products">
      <EntryTable
        aria-label="Products"
        type={Product}
        overview={productsOverview}
        filter={{brand: {has: {_entry: entry.id}}}}
        emptyMessage="No products for this brand yet"
      />
    </Field>
  )
}

EntryTable takes:

  • type: list entries of this type, or of an array of types.

  • filter: a query filter, such as {brand: {has: {_entry: entry.id}}}.

  • workspace, root, parentId: only list entries in this workspace or root, or the children of this entry (null for the top level).

  • overview: the columns and default order to use: an overview object, or the type or root that configures one. Defaults to the overview of the root or type that contains type.

  • columns: show these columns instead of those of the overview.

  • sort: the default order. Defaults to the sort of the overview, then the title.

  • locale: the locale of the entries, defaults to the locale selected in the dashboard.

  • limit: the maximum number of rows.

  • emptyMessage: shown when no entries match.

  • aria-label, className, style.

Tables with the same query share their data, so showing the same table twice doesn't load it twice.

Media library

The media root and its folders list a preview, the dimensions ("1200 × 800 px"), the file size and the file type of each file, without the status, type, updated and author columns. Pass an overview to Config.media({overview}) to replace these columns.

Its menu sorts by title, size, dimensions and file type, and filters on files or folders and on the kind of file: images, PDF, documents, spreadsheets, presentations, archives, video and audio. Folders stay listed while a file type is picked, so editors can still open them.

Custom dashboard pages

To add a page of your own to the dashboard, such as a report or a table of data from another system, create a root with a view. The root shows up with the other roots of the workspace, and its view renders as a full page:

cms.ts
import {Config} from 'alinea'
import {IcReport} from '@/icons'

export const reports = Config.root('Reports', {
  icon: IcReport,
  view: '@/views/Reports#Reports'
})
views/Reports.tsx
import {EntryTable, type RootViewProps} from 'alinea/cms'
import {Heading, Text} from 'alinea/components'
import {Product} from '@/schema/Product'

export function Reports({root}: RootViewProps) {
  return (
    <>
      <Heading>{root.label}</Heading>
      <Text>Products that are out of stock</Text>
      <EntryTable
        aria-label="Sold out products"
        type={Product}
        filter={{stock: 0}}
        emptyMessage="Everything is in stock"
      />
    </>
  )
}

The view receives RootViewProps: the configuration of the root, including its label. It can use everything in alinea/components, such as Table for rows you load yourself, the hooks of alinea/cms (useGraph, useNavigate, useLocale, ...) and EntryTable. A root without contains has an empty sidebar tree.

Prefer this over a type with a hidden title and a single Field.view: that creates entries in your content only to hold a view.

Two other options replace parts of the dashboard:

  • A type's view replaces the editor of entries of that type. It receives TypeViewProps: {type}.

  • A type's defaultView: 'overview' opens the overview of an entry's children first, instead of its form.

Upgrading from Alinea 1.x

  • summaryRow and summaryThumb are no longer rendered. Configure the columns on the parent with overview.columns, and the card image with overview.thumbnail.

  • orderChildrenBy still works, but is deprecated: use overview.sort.

  • The overview: true field option is deprecated. It's still used when the parent defines no overview.columns: the marked fields of the types of the listed children become columns, with the label of the field as header and up to five columns. A field name shared by several child types is one column, and types without it show "–". These columns sort by the same rules as other columns.