Skip to main content

Custom field type end to end

How a value on the backend becomes a component of your own on the screen — the chain format → schema → provider → component.

This is the one thing Habarico does differently from a hand-written admin, and the one thing worth understanding before you customise anything else. The example: a colour picker for a product's swatch colour.

The chain​

backend field schema frontend
──────────────── ──────────────────────── ─────────────────────────
format: "color" ──▶ {"name": "swatch", ──▶ inputProvider sees
"type": "string", format === 'color'
"format": "color"} ──▶ <ColorInput />

Three things have to line up: the backend has to emit a format nobody else claims, the provider has to recognise it, and the component has to accept the props the provider hands it. Nothing else — no registry, no build step, no shared enum.

1. Emit the format from the backend​

AdminCharField takes an explicit format:

from habarico.serializers import AdminCharField, AdminModelSerializer

from .models import Product


class ProductSerializer(AdminModelSerializer):
swatch = AdminCharField(format="color", required=False)

class Meta:
model = Product
fields = ["id", "name", "swatch"]


class ProductAdmin(ModelAdmin):
model = Product
base_serializer_class = ProductSerializer
fields = ["name", "swatch"]

For a field type you use often, put the format on a model field instead and let the serializer pick it up — see Custom model fields.

Check what actually arrives before touching the frontend. A field's format lives under endpoints, one entry per lower-case HTTP method — not under views, which carry the layout:

curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/v1/eshop/admin/product/schema/ \
| jq '.endpoints.get.fields[] | select(.name == "swatch")'

2. Write the component​

It receives InputProps and is responsible for reading and writing the form value — React Admin's useInput does that part:

// ColorInput.tsx
import {useInput} from 'react-admin';
import {InputProps} from 'habarico';

export const ColorInput = ({source, label, helperText, validate}: InputProps) => {
const {field, fieldState} = useInput({source, validate});

return (
<label>
{label}
<input type="color" value={field.value ?? '#ffffff'} onChange={field.onChange} />
{fieldState.error?.message ?? helperText}
</label>
);
};

3. Register it​

The provider is consulted before the built-in mapping, and returning nothing hands the decision back — so match narrowly and stay out of the way otherwise:

<SchemaAdmin
inputProvider={({format, props}) => {
if (format === 'color') {
return <ColorInput {...props} />;
}
}}
{...rest}
>
<SchemaResource name="eshop/product" />
</SchemaAdmin>

For display, do the same with fieldProvider:

fieldProvider={({format, props}) => {
if (format === 'color') {
return <ColorField {...props} />;
}
}}

Where to match​

Match onWhen
formatThe field type is a concept — every colour field everywhere behaves this way
source + resourceOne specific field on one specific resource needs special treatment
typeAlmost never — it replaces the component for every field of that JSON type

A resource-level provider on <SchemaResource> wins over the admin-wide one, so a general rule and a local exception can coexist.

When it does not render​

SymptomUsual cause
The built-in input still appearsThe provider returned undefined — check the format actually present in the schema, not the one you meant to emit
Nothing renders at allThe provider returned an element that throws, or the field is readOnly and the field provider decides instead
It renders but never savesThe component does not use useInput, so the value never reaches the form
tip

A read-only field is rendered by the field components, not the inputs. A custom type that appears in both editable and read-only views needs both providers.

See also​