QUESTPIE
AdminCollections

The list

One .list() call chooses the renderer and everything on it. Columns, sorting, filter presets, grouping, drag-to-reorder and the buttons around the rows.

View markdown

Every key

.list() runs once at load and stores what it returns. A second call replaces the first, so put everything in one call.

KeyWhat it doesRenderer
columnsField names shown after the title columnBoth
defaultSortThe starting sortBoth
defaultFiltersFilters applied before the reader touches anythingBoth
quickFiltersNamed filter presets in the headerBoth
groupingWhich fields a reader may group byBoth
actionsHeader, row and bulk buttonsBoth
orderableDrag rows to write an order valueTable
layoutWhich field is the title, subtitle, badge, metaList
outlineNested tree rowsList

Two renderers

.list(({ v, f }) => v.collectionTable({ columns: [f.status] }))
.list(({ v, f }) => v.listView({ layout: { subtitleField: f.slug } }))

collectionTable is the default. It is a real table with a column picker, column widths and a card layout on narrow screens. listView is a dense flex renderer built for hierarchies, where each row is a title with a few markers around it.

You can skip v and return a plain object. The default view is collection-table, so .list(({ f }) => ({ columns: [f.status] })) is the same call as the first line above.

Columns

columns adds to the title column. It does not replace it. The title is always first, and a name you repeat there is not drawn twice.

.list(({ v, f }) => v.collectionTable({ columns: [f.status, "updatedAt"] }))

Every other field stays available in the column picker, including the ones the default set leaves out.

With no columns at all the panel picks: the title, then up to six short fields, then createdAt when the collection has timestamps. Relations, uploads, rich text, blocks, JSON, objects, arrays and textareas are skipped in that default. They are wide, or they cost an extra query.

A reader who has picked their own columns keeps them. Their choice is stored per user, in admin_preferences, and it wins from then on. So adding a name to columns later reaches people who never touched the picker, and nobody else. Everyone can still switch it on themselves.

Sorting

defaultSort: { field: "updatedAt", direction: "desc" }

The sort resolves in this order. The reader's saved sort, then defaultSort, then the order field when the list is orderable, then createdAt descending. The field must be a declared field, _title, createdAt or updatedAt. Anything else is ignored rather than sent.

Filters

defaultFilters is the filter set a reader starts with. It applies until they change the filters themselves, and then the panel remembers theirs. Each rule needs its own id.

defaultFilters: [
	{ id: "live", field: "status", operator: "equals", value: "published" },
];

quickFilters are named presets, shown as buttons above the rows. Each one carries a whole rule set.

quickFilters: [
	{
		id: "drafts",
		label: { en: "Drafts", sk: "Koncepty" },
		filters: [{ id: "d", field: "status", operator: "equals", value: "draft" }],
	},
];

An operator is one of equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, in, not_in, some, every, none, is_empty and is_not_empty.

The filter builder a reader opens is not limited by config. It offers every field except _title, reverse relations and computed fields.

Grouping

Grouping runs in the query, not on the rows already fetched.

grouping: {
	fields: [f.status, f.author],
	defaultField: f.status,
	showCounts: true,
}

fields is the list a reader may choose from. Leave it out and nobody can group at all. defaultField is where they start. showCounts is on unless you set it to false, and each count is a database count for that group.

While grouping is on, a page is a page of groups. Every row in the groups on the page comes back, so a large group is a large response. A search turns the query grouping off, and the panel groups the rows it already has instead.

Reordering rows

orderable: true
orderable: { direction: "asc", step: 10 }

This adds a reorder button to the list header. The collection needs a numeric field named exactly order. The name is not configurable. The button only works when the list has no search, no filters and no grouping, and fits on one page. Turning it on sorts by order and gives every row a drag handle.

Dropping a row rewrites order on every row on the page as (index + 1) * step. step defaults to 10, so there is room to insert without renumbering the lot. direction decides how the ordered list reads and defaults to asc.

Keys that do nothing

ListViewConfig also declares realtime, searchable and filterable. None of them has any effect. Introspection drops realtime on the way out, and neither renderer reads the other two.

Live invalidation is on by default, from the AdminProvider. A reader turns it off per collection under View options, and that choice is saved with their view. What a search matches is decided by .searchable() on the collection itself. See Search.

Actions on the list

.list(({ v, f, a }) =>
	v.collectionTable({
		columns: [f.status],
		actions: {
			header: { primary: [a.create] },
			row: [a.delete],
			bulk: [a.deleteMany, a.duplicate],
		},
	}),
)

Each section you name replaces the default for that section. Leave a section out and the default stands. That is a Create button in the header, and Delete selected, Restore selected and Duplicate in the selection toolbar. The row menu is empty unless you fill it.

Declaring a custom action changes that. Any .actions({ custom }) rebuilds the header section, so the Create button goes unless you name it here. A bulk-scope custom action does the same to the selection toolbar.

Only four built-ins resolve here: a.create, a.delete, a.deleteMany and a.duplicate. Anything else in these arrays is dropped without a warning. Custom actions do not go here at all. They are declared in .actions() and placed by their scope. See Actions.

The dense renderer

layout and outline are read by v.listView() only. On a table they are carried and ignored.

layout says which field plays which part in a row.

KeyHolds
densitycompact or comfortable
titleFieldThe row title. Defaults to the collection title.
subtitleFieldOne line under the title
leadingFieldsMarkers before the title
badgeFieldsCompact badges beside the title
metaFieldsMuted metadata on the right

outline turns the flat list into a tree. Each entry in levels is one tier of nesting, and the four kinds differ in where the tier comes from.

kindGroups rows by
fieldA value on the row itself
relation-fieldA value on a related record
edgeA parent and child column on another collection, so a real tree
pathSegments of one string field, split on separator

defaultExpanded, maxDepth, showCounts and preserveMatchingBranches sit beside levels and apply to the whole tree. edge and path levels take repeat to recurse into themselves.

.list(({ v, f }) =>
	v.listView({
		layout: { titleField: f.name, metaFields: ["updatedAt"] },
		outline: {
			levels: [{ kind: "path", field: f.route, separator: "/" }],
			defaultExpanded: "roots",
		},
	}),
)

Where each topic lives

TopicPage
The other four methodsCollections and globals
Buttons of your own, and their handlersActions
Writing a renderer of your ownCustom views
Rows that leave the list but not the tableSoft delete

Next

The form is the other half of a collection's screens, and the one where fields can react to each other.

On this page