Skip to content

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:

fields/Range.ts
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 no initialValue.

  • 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:

fields/Range.view.tsx
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:

schema/Product.ts
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:

fields/Priority.view.tsx
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>
  )
}
fields/Priority.ts
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, like useState. The setter also accepts an updater function.

  • useFieldValue(field), useFieldSetter(field): only the value, or only the setter.

  • useFieldOptions(field): the field's options, including label and the resolved readOnly and hidden.

  • useFieldError(field): the validation message from required or validate, 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 its id, title, url, workspace, root, locale, status and the stored data. null outside an entry.

  • useLocale(): the locale being edited, or null for content without translations.

  • useGraph(): query content from the dashboard with the same API as cms, 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 a Field.view section.

  • EntryTable: a table of entries with the columns of an overview, for example to list related entries in a Field.view section.

  • FieldViewProps<F>, SectionViewProps, TypeViewProps, RootViewProps: the props the dashboard passes to a field view, a Field.view component, a type's view and a root's view.

  • OverviewCellProps, OverviewActionProps, OverviewEntry: the props of an overview column's view and 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:

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

export const Product = Config.document('Product', {
  fields: {
    title: Field.text('Title'),
    ...Field.view('@/fields/ProductUrls.view#ProductUrls')
  }
})
fields/ProductUrls.view.tsx
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:

  • useField from alinea/dashboard returns {value, mutator, options, label, error}. Replace it with the tuple useField from alinea/cms and the separate useFieldOptions and useFieldError. useFieldValue, useFieldOptions, useFieldError and useFieldKey also move to alinea/cms, and useFieldMutator becomes useFieldSetter.

  • InputLabel from alinea/dashboard is now the Field component. It no longer shows help from {...options}: use FieldChrome from alinea/cms, which reads it from the field, or pass the help text to Field from alinea/components as description.

  • useLocale, useGraph and useEntryEditor from alinea/dashboard/hook/* become useLocale, useGraph and useEntry from alinea/cms.

  • Hooks imported from alinea/dashboard/hooks still work, but that module is internal: import them from alinea/cms.

  • Components from alinea/ui were removed. Use alinea/components.

See Upgrading from 1.x for the other dashboard changes.