Custom fields
When the built-in fields don't fit your content, create your own. A custom field has two parts: a constructor function that you call in your schema, and a React component that renders the field in the dashboard.
Create a field
The constructor describes the value the field stores and its options, and points to the component that renders it:
import {Field} from 'alinea'
export type RangeField = Field.Create<
number,
{min?: number; max?: number; help?: string}
>
export function range(
label: string,
options: Field.Options<RangeField> = {}
): RangeField {
return Field.create({
label,
options,
// The module that renders the field, its default export is used
view: '@/fields/Range.view'
})
}Field.Create<Value, Options> is the type of a field that stores Value (anything JSON can hold) and accepts your Options next to the standard ones: initialValue, required, readOnly, hidden, shared, validate and width, the width of the field in the entry form. Queries return the stored value as it is.
To describe the options as an interface, extend FieldOptions from alinea/core and type the options argument with WithoutLabel:
import {Field} from 'alinea'
import type {FieldOptions, WithoutLabel} from 'alinea'
export interface RatingOptions extends FieldOptions<number> {
max?: number
}
export type RatingField = Field.Create<number, RatingOptions>
export function rating(
label: string,
options: WithoutLabel<RatingOptions> = {}
): RatingField {
return Field.create({label, options, view: '@/fields/Rating.view'})
}Field.create takes:
label: the label of the field.options: the options passed to your constructor.view: the component that renders the field, see Point to the view.defaultValue: a function returning the value of new entries, when the field has noinitialValue.searchableText: a function returning the text of a value to include in the dashboard search.
Render the field
The view receives the field as its field prop. Hooks from alinea/cms read and write its value, and FieldChrome renders the label, help text, shared badge and validation error around your control, like the built-in fields do:
import {
FieldChrome,
type FieldViewProps,
useField,
useFieldOptions
} from 'alinea/cms'
import {useId} from 'react'
import type {RangeField} from './Range'
export default function RangeView({field}: FieldViewProps<RangeField>) {
const [value, setValue] = useField(field)
const {min = 0, max = 10, readOnly} = useFieldOptions(field)
const id = useId()
return (
<FieldChrome field={field} htmlFor={id}>
<input
id={id}
type="range"
min={min}
max={max}
value={value ?? min}
disabled={readOnly}
onChange={event => setValue(Number(event.target.value))}
/>
</FieldChrome>
)
}FieldChrome takes the label, the help option, required, readOnly and shared from the field's options and the message from its validation. Pass any of these as a prop to override it.
Use the field in a type like any other:
import {Config} from 'alinea'
import {range} from '@/fields/Range'
export const Product = Config.document('Product', {
fields: {
rating: range('Rating', {min: 1, max: 5, help: 'From 1 to 5'})
}
})useFieldOptions returns the options with the dashboard's state applied: readOnly is also true when the user's role can't edit the field or the entry is not editable, so respect it in your view.
Point to the view
view is usually a module path, resolved from your project root with the path aliases of your tsconfig.json. The default export is used, append #Name for a named export: '@/fields/Priority.view#PriorityView'.
A path keeps dashboard code out of your site: your schema is imported by your pages and server components, and the view is only loaded by the dashboard. You can pass a component instead (view: RangeView), but then the component and the alinea/cms hooks it imports end up everywhere your schema is imported, including server components where they can't run. Use a path unless the field is only used in scripts.
Build views with the dashboard components
alinea/cms holds what is tied to the CMS: the hooks, FieldChrome and the view prop types. The generic building blocks are in alinea/components. Its form controls take the same label, description, error, required, readOnly and shared props and render their own chrome, so a field built on one of them looks native:
import {
type FieldViewProps,
useField,
useFieldError,
useFieldOptions
} from 'alinea/cms'
import {Select, SelectItem} from 'alinea/components'
import type {Priority, PriorityField} from './Priority'
export function PriorityView({field}: FieldViewProps<PriorityField>) {
const [value, setValue] = useField(field)
const options = useFieldOptions(field)
const error = useFieldError(field)
return (
<Select
label={options.label}
value={value}
onValueChange={next => setValue((next ?? 'normal') as Priority)}
error={error}
required={options.required}
readOnly={options.readOnly}
shared={options.shared}
>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="high">High</SelectItem>
</Select>
)
}import {Field} from 'alinea'
export type Priority = 'low' | 'normal' | 'high'
export type PriorityField = Field.Create<Priority>
export function priority(
label: string,
options: Field.Options<PriorityField> = {}
): PriorityField {
return Field.create({
label,
options,
defaultValue: (): Priority => 'normal',
view: '@/fields/Priority.view#PriorityView'
})
}The entry editor sets --alinea-header-height to the height of its header. A view that fills the visible editor can size itself with it, for example height: calc(100dvh - var(--alinea-header-height)).
Style your own markup with the theme tokens, such as --alinea-fg-muted, --alinea-border and --alinea-radius, so it follows light and dark mode. --alinea-workspace-color holds the color of the current workspace.
The alinea/cms API
Everything in alinea/cms works in field views and in the other components the dashboard renders for an entry: Field.view sections, entry actions and a type's view. A root's view is not part of an entry: the hooks that read the dashboard, such as useGraph, useNavigate, useLocale and useUser, and EntryTable work there, the field hooks, EditField and EditFields don't, and useEntry() returns null.
Field hooks, for the field passed to a view or another field of the same entry, list row or object:
useField(field): the stored value and a setter, likeuseState. The setter also accepts an updater function.useFieldValue(field),useFieldSetter(field): only the value, or only the setter.useFieldOptions(field): the field's options, includinglabeland the resolvedreadOnlyandhidden.useFieldError(field): the validation message fromrequiredorvalidate, if any.useFieldKey(field): the key the field is stored under, such as'title'.Other fields: pass any field of the same list row or object, of a parent row or of the entry to these hooks, it resolves in the nearest scope that holds it. Import the type from your schema (
useFieldValue(BlogPost.title)) or pass the field through your field's options. A view shared by types that each define their own field passes its key instead:useFieldValue('path')reads that field of the same row, untyped.useSiblingFieldValue(key)is deprecated.
A field in the rows of a list can read a field of the entry, here to suggest the post title as a caption:
import {
type FieldViewProps,
useField,
useFieldOptions,
useFieldValue
} from 'alinea/cms'
import {TextField} from 'alinea/components'
import {BlogPost} from '@/schema'
import type {CaptionField} from './Caption'
export function CaptionView({field}: FieldViewProps<CaptionField>) {
const [value, setValue] = useField(field)
const {label} = useFieldOptions(field)
// A field of the entry, read from inside a list row
const title = useFieldValue(BlogPost.title)
return (
<TextField
label={label}
value={value}
placeholder={title}
onValueChange={setValue}
/>
)
}Entry and dashboard hooks:
useEntry(): the entry being edited, with itsid,type,title,path,url,locale,status,workspace,root,parentIdanddata.dataholds the field values as saved, read the live value of a field while it is edited withuseFieldValue(field).nulloutside an entry.useLocale(): the locale being edited, ornullfor content without translations.useGraph(): query content from the dashboard with the same API ascms, for example to list entries in a picker.useUser(),usePolicy(): the signed-in user and their permissions.useNavigate(): returns a function that opens an entry or root in the dashboard:navigate({entryId, locale})looks up the entry's workspace and root,navigate({workspace, root})opens a root. Passview: 'edit'orview: 'overview'to pick the form or the list of children. The editor is asked to confirm first when there are unsaved changes.href(location)fromalinea: the href of the same locations, for a link that also opens in a new tab:<a href={href({entryId: post.id})}>. It is a plain function, so it also works in table rows, in thehelpof a field and on the server, where you prefix the dashboard's url:${baseUrl}/admin${href({entryId})}.usePreviewMetadata(): the title, description and other metadata the live preview reports for the current page.
Components and types:
FieldChrome: the label, help text and error of a field around your own control.EditField,EditFields: render a field, or a record of fields, with the view it is configured with, for example to lay out existing fields in aField.viewsection. Pass an object field toEditFieldsto render its fields, or some of them, in the scope of the object:<EditFields field={Product.dimensions} fields={{width: Product.dimensions.width}} />.EntryTable: a table of entries with the columns of an overview, for example to list related entries in aField.viewsection.FieldViewProps<F>,SectionViewProps,TypeViewProps,RootViewProps: the props the dashboard passes to a field view, aField.viewcomponent, a type'sviewand a root'sview.OverviewCellProps,OverviewActionProps,OverviewEntry: the props of an overview column'sviewand of an overview action, and the entry they receive.EntryActionPropsandDashboardEntry: the props of an entry action and the entryuseEntry()returns.
Show a component between fields
Field.view adds a component to a type that doesn't store a value, for example to show instructions or information derived from the entry. Spread it into fields:
import {Config, Field} from 'alinea'
export const Product = Config.document('Product', {
fields: {
title: Field.text('Title'),
...Field.view('@/fields/ProductUrls.view#ProductUrls')
}
})import {useEntry} from 'alinea/cms'
import {Field, Link} from 'alinea/components'
export function ProductUrls() {
const entry = useEntry()
if (!entry) return null
return (
<Field label="Public URL" description="Where this product is published">
<Link href={entry.url} target="_blank">
{entry.url}
</Link>
</Field>
)
}Replace the form of a type
A type's view replaces the form of its entries with your own component, for example to edit the fields in a layout of your own. The entry header stays above it, with the title, the status, the publish and draft buttons, the type's actions and the sidebar with history and references. The view fills the rest of the editor, without padding.
import {Config, Field} from 'alinea'
export const Product = Config.document('Product', {
view: '@/views/ProductView.view#ProductView',
fields: {
summary: Field.text('Summary', {multiline: true}),
price: Field.number('Price')
}
})import {EditField, useField} from 'alinea/cms'
import {TextField} from 'alinea/components'
import {Product} from '@/schema/Product'
export function ProductView() {
const [summary = '', setSummary] = useField(Product.summary)
return (
<div style={{display: 'grid', gap: 16, padding: 24}}>
<TextField
label="Summary"
multiline
value={summary}
onValueChange={setSummary}
/>
<EditField field={Product.price} />
</div>
)
}The view receives TypeViewProps: the type of the entry. It edits the entry like the form does: the field hooks, EditField and EditFields read and write the fields of the entry, the changes show up as unsaved edits in the header and are kept until the editor publishes or saves a draft, and leaving the entry with unsaved changes asks to confirm first. To fill the editor, size the view with height: 100%.
Add actions to the entry header
Buttons that act on the entry, such as an export or a button that creates a related entry, go in the actions of its type. They render in the entry header next to the publish button and receive EntryActionProps: the entry and the locale it is edited in. The hooks of alinea/cms read and write its fields.
import {Config, Field} from 'alinea'
export const Product = Config.document('Product', {
fields: {title: Field.text('Title')},
actions: ['@/views/ExportProduct.view#ExportProduct']
})import {type EntryActionProps, useFieldValue} from 'alinea/cms'
import {Button} from 'alinea/components'
import {Product} from '@/schema/Product'
export function ExportProduct({entry}: EntryActionProps) {
const title = useFieldValue(Product.title)
return (
<Button onClick={() => window.open(`/api/export/${entry.id}`)}>
Export {title}
</Button>
)
}Upgrading from Alinea 1.x
Field views from 1.x keep working, with deprecated APIs:
useFieldfromalinea/dashboardreturns{value, mutator, options, label, error}. Replace it with the tupleuseFieldfromalinea/cmsand the separateuseFieldOptionsanduseFieldError.useFieldValue,useFieldOptions,useFieldErroranduseFieldKeyalso move toalinea/cms, anduseFieldMutatorbecomesuseFieldSetter.InputLabelfromalinea/dashboardis now theFieldcomponent. It no longer showshelpfrom{...options}: useFieldChromefromalinea/cms, which reads it from the field, or pass the help text toFieldfromalinea/componentsasdescription.useLocale,useGraphanduseEntryEditorfromalinea/dashboard/hook/*becomeuseLocale,useGraphanduseEntryfromalinea/cms.useEntryEditorstill works as a shim for views that only read the entry:activeVersionhas theid,locale,path,status,titleandtypeof the entry, andactiveVersion.dataonly haspath. Reading another field of it throws, read field values withuseFieldValue(field)instead.useEntry()returns the same entry as aDashboardEntry, with its saved field values indata.Hooks imported from
alinea/dashboard/hooksstill work, but that module is internal: import them fromalinea/cms.Components from
alinea/uiwere removed. Usealinea/components.
See Upgrading from 1.x for the other dashboard changes.