Розробка кастомних 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="+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 день на налаштування сборки та документацію.