В стандартной поставке KeystoneJS есть все необходимые поля: text, integer, relationship, image. Но рано или поздно упрёшься в ограничения: нужно хранить цвет с прозрачностью, валидировать номер телефона по маске или интегрировать внешний API для автодополнения. Тогда и приходят на помощь кастомные поля — полноценные расширения, которые включают тип базы данных, GraphQL-резолверы и React-компоненты для Admin UI.
За 5 лет работы с KeystoneJS мы реализовали десятки таких полей — от простых масок до мульти-столбцовых структур. Каждый раз это снижает время разработки на 30–50% по сравнению с костылями в фронтенде. Наша команда имеет 5+ лет опыта в KeystoneJS и реализовала более 20 кастомных полей для различных проектов. Ниже расскажу, из чего состоит кастомное поле и как написать своё.
Задачи, которые решают кастомные поля
- Нестандартный формат данных. Телефон с маской, цвет с прозрачностью, гео-координаты — стандартные поля не дают такой гибкости. Например, для интернет-магазина нужно хранить цвет товара в hex и отдельно прозрачность. Без кастомного поля пришлось бы создавать два поля и писать валидацию на фронтенде.
- Сложная валидация. Проверка по внешнему API, cross-field правила (если поле A заполнено, поле B обязательно), уникальность составных ключей. Всё это реализуется через хуки KeystoneJS без дублирования кода на клиенте.
- Кастомный UI. Автокомплит с внешним источником, визуальный редактор, drag-and-drop — любые интерфейсные задачи, которые не покрываются стандартными полями. React-компоненты позволяют встроить любой UI и легко его тестировать.
- Производительность. Комбинированные индексы, оптимизированное хранение под частые запросы — кастомное поле даёт полный контроль над схемой БД.
Как создать кастомное поле для валидации телефона?
Кастомное поле в KeystoneJS состоит из трёх слоёв: DB Layer (как данные хранятся в Prisma/БД), GraphQL Layer (типы для чтения/записи через API) и Admin UI Layer (React-компоненты для отображения и редактирования). Рассмотрим на примере поля Phone Number с форматированием.
Поле хранит телефон как строку, но предоставляет UI с маской ввода и валидацию формата. В hooks.validateInput проверяем регулярное выражение, а в resolve для input очищаем строку от лишних символов.
// 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;
},
});
Использование в списке:
import { phoneNumber } from './fields/phoneNumber';
export const Customer = list({
fields: {
name: text({ validation: { isRequired: true } }),
phone: phoneNumber({ validation: { isRequired: true }, isIndexed: true }),
altPhone: phoneNumber(),
},
});
«Кастомные поля — ключевой элемент гибкой CMS», — отмечает команда KeystoneJS в документации.
Почему KeystoneJS лучше Strapi для нестандартных полей?
KeystoneJS выигрывает в гибкости: вы определяете полный стек — от схемы БД до React-компонентов — без ограничений. Strapi удобен для быстрых решений, но кастомизация там сводится к замене частей кода, а не к созданию модульного расширения. KeystoneJS лучше подходит для проектов, где требуется нестандартная логика или уникальный UI. Средняя экономия времени на разработку функционала с помощью кастомных полей составляет 40%.
Процесс разработки и сроки
- Анализ требований — определяем формат данных, валидацию, UI, необходимые фильтры.
- Проектирование схемы — выбираем тип поля (scalar/multi), проектируем Prisma-модель.
- Разработка — пишем GraphQL-резолверы, React-компоненты, хуки.
- Тестирование — юнит-тесты на валидацию и трансформацию, интеграционные на работу в контексте списка.
- Интеграция и деплой — подключаем поле к проекту, проверяем в Admin UI.
| Тип поля | Время |
|---|---|
| Простое поле (один столбец, кастомный UI) | 1–2 дня |
| Поле с несколькими столбцами | 2–3 дня |
| Поле с внешними API (Mapbox, Unsplash picker) | 3–5 дней |
| Поле с фильтрами и сортировкой | +0.5–1 день |
Публикация как npm-пакета для переиспользования между проектами добавляет 0.5–1 день на настройку сборки и документацию.
Что входит в работу
| Результат | Описание |
|---|---|
| Исходный код поля | TypeScript-модуль с полным набором файлов (index, views, controller) |
| Документация | API-документация и примеры использования в вашем проекте |
| Тесты | Юнит-тесты на валидацию, хуки и GraphQL-слой |
| Интеграция | Подключение поля к вашей схеме и настройка Admin UI |
| Поддержка после запуска | 2 недели бесплатной поддержки по устранению возможных проблем |
Готовы обсудить ваше кастомное поле? Свяжитесь с нами — мы бесплатно оценим задачу и предложим решение. Обращайтесь за бесплатной консультацией.







