В стандартной поставке 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 недели бесплатной поддержки по устранению возможных проблем |
Готовы обсудить ваше кастомное поле? Свяжитесь с нами — мы бесплатно оценим задачу и предложим решение. Обращайтесь за бесплатной консультацией.







