Розробка кастомних 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('Ціна не може бути від\'ємною');
}
},
// Після успішного збереження — side effects
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 днів.







