Розробка кастомних 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="+38 (095) 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(),
},
});
Терміни розробки
| Тип поля | Час |
|---|---|
| Простий field (один стовпець, кастомний UI) | 1–2 дні |
| Поле з кількома стовпцями | 2–3 дні |
| Поле з зовнішніми API (Mapbox, Unsplash picker) | 3–5 днів |
| Поле з фільтрами та сортуванням | +0.5–1 день |
Публікація як npm-пакету для переиспользування між проектами додає 0.5–1 день на налаштування сборки та документацію.







