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 and validate. Queries return the stored value as it is.
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 alinea/cms API
Everything in alinea/cms works in field views and in the other components the dashboard renders: Field.view sections and custom type and root views.
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'.useSiblingFieldValue(key): the value of another field in the same entry, list row or object, by its key.
Entry and dashboard hooks:
useEntry(): the entry being edited, with itsid,title,url,workspace,root,locale,statusand the storeddata.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({workspace, root, entryId}). The editor is asked to confirm first when there are unsaved changes.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.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.
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>
)
}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.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.