A saved view is the named slice of a collection an editor returns to: filter, sort, page size and columns, Personal or Shared.
It is not a separate package. The server half is a second ServerPlugin entry
exported from @orthacms/content-server; the admin half is part of
@orthacms/content-admin. (A plugin carries one migration descriptor, and
content’s already points at your generated tables.)
Install
Nothing to install.
Register
// apps/server/src/plugins.ts
import { ContentPlugin, ContentViewsPlugin } from '@orthacms/content-server';
export function buildPlugins(config: OrthaCmsConfig): ServerPlugin[] {
const content = ContentPlugin({ types: contentTypes, migrations: { /* … */ } });
return [
// DatabasePlugin, IdentityPlugin, WorkspacesPlugin, …
content,
ContentViewsPlugin({ content }) // after identity, workspaces and content
];
}- Must come after identity and workspaces:
saved_viewshas foreign keys tousersandworkspaces, and migrations run in list order. - Takes
contentby value — it needs the registry to resolvecontent:<typeName>. - Its migrations are tracked in
__drizzle_migrations_content_views. - The admin needs nothing extra:
ContentPlugin()already draws the views as a row of tabs above the records toolbar — All records, each saved view, a View button, and Trash at the end on a collection with a trash. Each view’s menu (Rename, Duplicate, Copy link, your default, sharing, Delete) is on its own tab: click the selected tab again, right-click it, or press Shift+F10.
Configuration
None. No options, no environment variables.
Routes
Admin routes: session cookie and X-Workspace-Id — see calling admin
routes. The whole surface requires
content:read.
| Method | Path | Needs | Returns |
|---|---|---|---|
GET | /api/views?scope=content:<type> | content:read | SavedView[] — yours plus every shared one |
POST | /api/views | content:read, plus views:share to share | SavedView |
PATCH | /api/views/:id | owner; views:share to share | SavedView |
DELETE | /api/views/:id | owner | 204 |
PUT | /api/views/:id/default | content:read | 204 — your default for that list |
DELETE | /api/views/:id/default | content:read | 204, idempotent |
curl -X POST https://cms.example.com/api/views \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-H 'Content-Type: application/json' \
-d '{
"scope": "content:article",
"name": "Drafts to review",
"visibility": "workspace",
"makeDefault": true,
"payload": {
"filter": "{\"and\":[{\"field\":\"status\",\"op\":\"eq\",\"value\":\"draft\"}]}",
"sort": "-updatedAt",
"pageSize": 25,
"columns": ["title", "status", "updatedAt"],
"extra": { "locale": "en" }
}
}' const filter = { and: [{ field: 'status', op: 'eq', value: 'draft' }] };
const view = await fetch('/api/views', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Workspace-Id': workspaceId },
body: JSON.stringify({
scope: 'content:article',
name: 'Drafts to review',
visibility: 'workspace',
payload: {
filter: JSON.stringify(filter), // a string, exactly as in ?filter=
sort: '-updatedAt',
columns: ['title', 'status', 'updatedAt']
}
})
}).then((r) => r.json()); {
"id": "d9e0…",
"scope": "content:article",
"name": "Drafts to review",
"visibility": "workspace",
"ownerId": "8a0e…",
"isOwn": true,
"isDefault": true,
"payload": {
"filter": "{\"and\":[{\"field\":\"status\",\"op\":\"eq\",\"value\":\"draft\"}]}",
"sort": "-updatedAt",
"pageSize": 25,
"columns": ["title", "status", "updatedAt"],
"extra": { "locale": "en" }
},
"updatedAt": "2026-09-30T09:30:00.000Z"
}PATCH takes only what changes — { "name": … }, { "visibility": "private" }
or { "payload": … }.
The payload
It mirrors the records page’s URL parameters, so a view round-trips through the same code as a hand-edited link.
| Key | Holds |
|---|---|
filter | The raw ?filter= JSON string — see filtering |
sort | updatedAt or -updatedAt style |
pageSize | Rows per page |
columns | Visible column ids, in display order |
extra | Slot-owned list params (e.g. i18n’s locale), a flat string map |
searchandpageare not stored: a view always opens on page 1 with no search.extrakeys come from thelistParamKeysofRECORDS_TOOLBAR_SLOTitems at runtime, so they are not typed.- The server checks the shape only. A view can outlive a field it mentions; the admin skips columns the type no longer has, and says how many.
Rules
visibility:private(default) orworkspace. Sharing needsviews:share(admin, contributor — not viewer).- Only the owner edits or deletes —
403otherwise, even for an admin. The remedy is Save as new. - Defaults are per user (
saved_view_defaults). Making a shared view your default does not change anyone else’s. - A view is a bookmark, not a grant. It replays through the ordinary list query with the reader’s own permissions and grants.
scopemust becontent:<typeName>. An unknown or ungranted type is the same404.- Create, update and delete are audited as
saved_view.created,saved_view.updated,saved_view.deleted. Choosing a default is not.
Limits
| Limit | Value | Over it |
|---|---|---|
| Name length | 80 | 400 |
| Scope key length | 128 | 400 |
| Columns per view | 100 | 400 |
| Column id length | 128 | 400 |
extra keys | 20 | 400 |
extra key or value length | 255 | 400 |
| Views per user, per workspace and scope | 100 | 409 |
A name is unique per owner within a workspace and scope — a duplicate is 409.