Разработка кастомных Lists (моделей) KeystoneJS
Lists — основная единица модели данных в KeystoneJS. Каждый List соответствует таблице в базе данных, набору GraphQL-операций и разделу в Admin UI. Правильная архитектура Lists определяет гибкость всей системы.
Анатомия List
import { list } from '@keystone-6/core';
import { text, relationship, timestamp, integer, virtual } from '@keystone-6/core/fields';
export const Product = list({
// Управление доступом на уровне операций и полей
access: { ... },
// Поля — ключевая часть
fields: { ... },
// Хуки жизненного цикла
hooks: { ... },
// Настройки Admin UI
ui: { ... },
// GraphQL-расширения
graphql: { ... },
// Описание для документации
description: 'Товары каталога',
});
Типы полей и их применение
fields: {
// Базовые
name: text({ validation: { isRequired: true }, isIndexed: true }),
sku: text({ isIndexed: 'unique' }),
price: integer({ validation: { min: 0 } }),
description: text({ ui: { displayMode: 'textarea' } }),
// Файлы и изображения
mainImage: image({ storage: 's3_images' }),
catalogPdf: file({ storage: 's3_files' }),
// Связи
category: relationship({ ref: 'Category.products' }),
tags: relationship({ ref: 'Tag', many: true }),
variants: relationship({ ref: 'ProductVariant.product', many: true }),
// Временные метки
createdAt: timestamp({
defaultValue: { kind: 'now' },
ui: { createView: { fieldMode: 'hidden' } },
}),
updatedAt: timestamp({
db: { updatedAt: true },
ui: { createView: { fieldMode: 'hidden' } },
}),
// Виртуальное поле (не хранится в БД)
fullPriceWithTax: virtual({
field: graphql.field({
type: graphql.Float,
resolve(item) {
return (item.price || 0) * 1.2;
},
}),
}),
},
Хуки жизненного цикла
hooks: {
// Перед записью в БД — модификация данных
resolveInput: async ({ resolvedData, inputData, item, operation }) => {
if (operation === 'create' && !inputData.sku) {
resolvedData.sku = `PROD-${Date.now()}`;
}
return resolvedData;
},
// Валидация перед операцией
validateInput: async ({ resolvedData, addValidationError }) => {
if (resolvedData.price !== undefined && resolvedData.price < 0) {
addValidationError('Цена не может быть отрицательной');
}
},
// После успешного сохранения — сайд-эффекты
afterOperation: async ({ operation, item, originalItem, context }) => {
if (operation === 'update' && item.status === 'published' && originalItem?.status !== 'published') {
// Инвалидация кэша, уведомление webhook, etc.
await invalidateCache(`/products/${item.slug}`);
}
},
// Перед удалением
beforeOperation: async ({ operation, item, context }) => {
if (operation === 'delete') {
// Проверяем, нет ли связанных заказов
const ordersCount = await context.db.OrderItem.count({
where: { product: { id: { equals: item.id } } },
});
if (ordersCount > 0) {
throw new Error('Нельзя удалить товар, присутствующий в заказах');
}
}
},
},
Двусторонние отношения
KeystoneJS требует явного описания обеих сторон отношения:
// Category.ts
export const Category = list({
fields: {
name: text({ validation: { isRequired: true } }),
products: relationship({ ref: 'Product.category', many: true }),
},
});
// Product.ts — обратная сторона
category: relationship({ ref: 'Category.products' }),
Prisma автоматически создаёт нужные внешние ключи.
Кастомные GraphQL-мутации
Иногда нужна бизнес-логика, которую неудобно реализовывать через стандартные CRUD-операции:
// keystone.ts — extendGraphqlSchema
extendGraphqlSchema: graphql.extend((base) => ({
mutation: {
publishProduct: graphql.field({
type: base.object('Product'),
args: { id: graphql.arg({ type: graphql.nonNull(graphql.ID) }) },
async resolve(source, { id }, context) {
if (!context.session?.data?.role === 'editor') {
throw new Error('Access denied');
}
return context.db.Product.updateOne({
where: { id },
data: { status: 'published', publishedAt: new Date() },
});
},
}),
},
})),
Admin UI: настройка отображения
ui: {
label: 'Товары',
singular: 'Товар',
plural: 'Товары',
listView: {
initialColumns: ['name', 'sku', 'price', 'status', 'category'],
initialSort: { field: 'createdAt', direction: 'DESC' },
pageSize: 50,
},
searchFields: ['name', 'sku'],
// Скрыть из Admin UI (только API)
isHidden: false,
},
Сроки разработки
Один хорошо проработанный List со связями, хуками и кастомным UI — 0.5–1 рабочий день. Полная модель данных для интернет-магазина (10–15 Lists) — 5–8 дней.







