Разработка кастомных Fields KeystoneJS

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка кастомных Fields KeystoneJS
Сложная
~2-3 рабочих дня
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Разработка кастомных Fields KeystoneJS

Встроенные поля KeystoneJS (text, integer, relationship, image) покрывают большинство случаев, но иногда нужна специфическая логика хранения или уникальный UI в Admin. Кастомные поля — это полноценные расширения с типами базы данных, GraphQL-резолверами и React-компонентами для Admin UI.

Архитектура кастомного поля

Кастомное поле в KeystoneJS состоит из трёх слоёв:

  1. DB Layer — как данные хранятся в Prisma/БД (один или несколько столбцов)
  2. GraphQL Layer — типы для чтения/записи через API
  3. 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="+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;
  },
});

Использование в 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(),
  },
});

Пример: поле с несколькими столбцами — Color с hex и opacity

// Поле хранит два значения: hex-цвет и opacity
export function colorField<ListTypeInfo extends BaseListTypeInfo>(): FieldTypeFunc<ListTypeInfo> {
  return (meta) =>
    fieldType({
      kind: 'multi',
      fields: {
        hex: { kind: 'scalar', mode: 'optional', scalar: 'String' },
        opacity: { kind: 'scalar', mode: 'optional', scalar: 'Float' },
      },
    })({
      ...meta,
      input: {
        create: {
          arg: graphql.arg({
            type: graphql.inputObject({
              name: `${meta.listKey}${meta.fieldKey}CreateInput`,
              fields: {
                hex: graphql.arg({ type: graphql.String }),
                opacity: graphql.arg({ type: graphql.Float }),
              },
            }),
          }),
          resolve: ({ hex, opacity }) => ({ hex, opacity }),
        },
        update: {
          arg: graphql.arg({
            type: graphql.inputObject({
              name: `${meta.listKey}${meta.fieldKey}UpdateInput`,
              fields: {
                hex: graphql.arg({ type: graphql.String }),
                opacity: graphql.arg({ type: graphql.Float }),
              },
            }),
          }),
          resolve: (value) => value,
        },
      },
      output: graphql.field({
        type: graphql.object<{ hex: string | null; opacity: number | null }>()({
          name: `${meta.listKey}${meta.fieldKey}Output`,
          fields: {
            hex: graphql.field({ type: graphql.String }),
            opacity: graphql.field({ type: graphql.Float }),
          },
        }),
        resolve: ({ value }) => value,
      }),
      views: require.resolve('./colorViews'),
    });
}

Фильтры для кастомного поля

filterQuery: {
  arg: graphql.arg({
    type: graphql.inputObject({
      name: `${meta.listKey}${meta.fieldKey}Filter`,
      fields: {
        equals: graphql.arg({ type: graphql.String }),
        contains: graphql.arg({ type: graphql.String }),
        startsWith: graphql.arg({ type: graphql.String }),
      },
    }),
  }),
  resolve(value) {
    return {
      hex: {
        equals: value?.equals,
        contains: value?.contains,
        startsWith: value?.startsWith,
      },
    };
  },
},

Тестирование кастомных полей

import { getContext } from '@keystone-6/core/testing';
import { config } from '../keystone';

describe('phoneNumber field', () => {
  let context: KeystoneContext;

  beforeAll(async () => {
    context = await getContext(config, PrismaClient);
  });

  it('normalizes phone on save', async () => {
    const customer = await context.db.Customer.createOne({
      data: { name: 'Test', phone: '+7 (999) 123-45-67' },
    });
    expect(customer.phone).toBe('+79991234567');
  });

  it('rejects invalid phone', async () => {
    await expect(
      context.db.Customer.createOne({ data: { name: 'Test', phone: 'not-a-phone' } })
    ).rejects.toThrow('Неверный формат телефона');
  });
});

Сроки разработки

Тип поля Время
Простое поле (один столбец, кастомный UI) 1–2 дня
Поле с несколькими столбцами 2–3 дня
Поле с внешними API (Mapbox, Unsplash picker) 3–5 дней
Поле с фильтрами и сортировкой +0.5–1 день

Публикация как npm-пакета для переиспользования между проектами добавляет 0.5–1 день на настройку сборки и документацию.