Разработка кастомных Fields KeystoneJS
Встроенные поля KeystoneJS (text, integer, relationship, image) покрывают большинство случаев, но иногда нужна специфическая логика хранения или уникальный UI в Admin. Кастомные поля — это полноценные расширения с типами базы данных, GraphQL-резолверами и React-компонентами для Admin UI.
Архитектура кастомного поля
Кастомное поле в KeystoneJS состоит из трёх слоёв:
- DB Layer — как данные хранятся в Prisma/БД (один или несколько столбцов)
- GraphQL Layer — типы для чтения/записи через API
- Admin UI Layer — React-компоненты для отображения и редактирования
fieldType(dbConfig)
├── getAdminMeta() // метаданные для UI
├── views (React)
│ ├── Field // компонент редактирования
│ ├── Cell // ячейка в списке
│ ├── CardValue // отображение в карточке связи
│ └── controller.ts // клиентская логика
└── graphql
├── input // тип для мутаций
├── output // тип для запросов
└── filters // типы для where-фильтров
Пример: поле Phone Number с форматированием
Поле хранит телефон как строку, но предоставляет UI с маской ввода и валидацию формата.
// fields/phoneNumber/index.ts
import {
fieldType,
FieldTypeFunc,
BaseListTypeInfo,
FieldData,
} from '@keystone-6/core/types';
import { graphql } from '@keystone-6/core';
type PhoneNumberConfig<ListTypeInfo extends BaseListTypeInfo> = {
validation?: { isRequired?: boolean };
defaultValue?: string;
isIndexed?: boolean | 'unique';
db?: { isNullable?: boolean; map?: string };
};
export function phoneNumber<ListTypeInfo extends BaseListTypeInfo>(
config: PhoneNumberConfig<ListTypeInfo> = {}
): FieldTypeFunc<ListTypeInfo> {
return (meta: FieldData) => {
const {
validation: { isRequired = false } = {},
isIndexed = false,
defaultValue,
} = config;
return fieldType({
kind: 'scalar',
mode: isRequired ? 'required' : 'optional',
scalar: 'String',
isIndexed,
default: defaultValue ? { kind: 'literal', value: defaultValue } : undefined,
})({
...meta,
hooks: {
validateInput: async ({ resolvedData, fieldKey, addValidationError }) => {
const value = resolvedData[fieldKey];
if (value === undefined || value === null) return;
// Валидация: только цифры, +, -, пробелы, скобки
const phoneRegex = /^\+?[\d\s\-()]{7,20}$/;
if (!phoneRegex.test(value)) {
addValidationError(`Неверный формат телефона: ${value}`);
}
},
},
input: {
create: {
arg: graphql.arg({ type: graphql.String }),
resolve: (value) => (value ? normalizePhone(value) : null),
},
update: {
arg: graphql.arg({ type: graphql.String }),
resolve: (value) => (value === undefined ? undefined : value ? normalizePhone(value) : null),
},
},
output: graphql.field({ type: graphql.String }),
views: require.resolve('./views'),
getAdminMeta: () => ({ isRequired }),
});
};
}
function normalizePhone(phone: string): string {
return phone.replace(/\s+/g, '').replace(/[()]/g, '');
}
// fields/phoneNumber/views.tsx
import React, { useState } from 'react';
import { FieldProps, controller } from '@keystone-6/core/fields';
export const Field = ({ field, value, onChange, autoFocus }: FieldProps<typeof controller>) => {
const [inputValue, setInputValue] = useState(value || '');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
setInputValue(raw);
onChange?.(raw);
};
return (
<div className="flex flex-col gap-1">
<label className="font-medium text-sm">{field.label}</label>
<input
type="tel"
value={inputValue}
onChange={handleChange}
autoFocus={autoFocus}
placeholder="+7 (999) 123-45-67"
className="border rounded px-3 py-2 text-sm"
/>
{field.adminMeta.isRequired && !value && (
<span className="text-red-500 text-xs">Обязательное поле</span>
)}
</div>
);
};
export const Cell = ({ item, field }) => (
<span>{item[field.path] || '—'}</span>
);
export const CardValue = ({ item, field }) => (
<span>{item[field.path] || 'Не указан'}</span>
);
export const controller = (config) => ({
path: config.path,
label: config.label,
description: config.description,
adminMeta: config.fieldMeta,
graphqlSelection: config.path,
defaultValue: '',
deserialize: (data) => data[config.path] ?? '',
serialize: (value) => ({ [config.path]: value || null }),
validate: (value) => {
if (config.fieldMeta.isRequired && !value) return false;
return true;
},
});
Использование в List:
import { phoneNumber } from './fields/phoneNumber';
export const Customer = list({
fields: {
name: text({ validation: { isRequired: true } }),
phone: phoneNumber({ validation: { isRequired: true }, isIndexed: true }),
altPhone: phoneNumber(),
},
});
Пример: поле с несколькими столбцами — Color с hex и opacity
// Поле хранит два значения: hex-цвет и opacity
export function colorField<ListTypeInfo extends BaseListTypeInfo>(): FieldTypeFunc<ListTypeInfo> {
return (meta) =>
fieldType({
kind: 'multi',
fields: {
hex: { kind: 'scalar', mode: 'optional', scalar: 'String' },
opacity: { kind: 'scalar', mode: 'optional', scalar: 'Float' },
},
})({
...meta,
input: {
create: {
arg: graphql.arg({
type: graphql.inputObject({
name: `${meta.listKey}${meta.fieldKey}CreateInput`,
fields: {
hex: graphql.arg({ type: graphql.String }),
opacity: graphql.arg({ type: graphql.Float }),
},
}),
}),
resolve: ({ hex, opacity }) => ({ hex, opacity }),
},
update: {
arg: graphql.arg({
type: graphql.inputObject({
name: `${meta.listKey}${meta.fieldKey}UpdateInput`,
fields: {
hex: graphql.arg({ type: graphql.String }),
opacity: graphql.arg({ type: graphql.Float }),
},
}),
}),
resolve: (value) => value,
},
},
output: graphql.field({
type: graphql.object<{ hex: string | null; opacity: number | null }>()({
name: `${meta.listKey}${meta.fieldKey}Output`,
fields: {
hex: graphql.field({ type: graphql.String }),
opacity: graphql.field({ type: graphql.Float }),
},
}),
resolve: ({ value }) => value,
}),
views: require.resolve('./colorViews'),
});
}
Фильтры для кастомного поля
filterQuery: {
arg: graphql.arg({
type: graphql.inputObject({
name: `${meta.listKey}${meta.fieldKey}Filter`,
fields: {
equals: graphql.arg({ type: graphql.String }),
contains: graphql.arg({ type: graphql.String }),
startsWith: graphql.arg({ type: graphql.String }),
},
}),
}),
resolve(value) {
return {
hex: {
equals: value?.equals,
contains: value?.contains,
startsWith: value?.startsWith,
},
};
},
},
Тестирование кастомных полей
import { getContext } from '@keystone-6/core/testing';
import { config } from '../keystone';
describe('phoneNumber field', () => {
let context: KeystoneContext;
beforeAll(async () => {
context = await getContext(config, PrismaClient);
});
it('normalizes phone on save', async () => {
const customer = await context.db.Customer.createOne({
data: { name: 'Test', phone: '+7 (999) 123-45-67' },
});
expect(customer.phone).toBe('+79991234567');
});
it('rejects invalid phone', async () => {
await expect(
context.db.Customer.createOne({ data: { name: 'Test', phone: 'not-a-phone' } })
).rejects.toThrow('Неверный формат телефона');
});
});
Сроки разработки
| Тип поля | Время |
|---|---|
| Простое поле (один столбец, кастомный UI) | 1–2 дня |
| Поле с несколькими столбцами | 2–3 дня |
| Поле с внешними API (Mapbox, Unsplash picker) | 3–5 дней |
| Поле с фильтрами и сортировкой | +0.5–1 день |
Публикация как npm-пакета для переиспользования между проектами добавляет 0.5–1 день на настройку сборки и документацию.







