# Компоненты и поля

Компонент («инфоблок» в терминах легаси) — тип раздела. Он описывает и форму
данных, и поведение: есть ли у объектов внутренняя страница, как объект
находится по слагу, какой шаблон вывода используется.

Один раздел — один компонент. Раздел ссылается на компонент по читаемому ключу
(`news`, `catalog`, `textpage`), а не по числу в имени таблицы, как
легаси-`Message2001`: ключ читается в коде, в дампе и в конфигурации и не
ломается при переносе данных между установками.

## Контракт

```php
interface Component
{
    public function key(): string;                  // 'news'
    public function label(): string;                // 'Новости'
    public function objectLabel(): string;          // 'Новость'
    public function table(): string;
    public function modelClass(): string;

    /** @return array<string, FieldDefinition> */
    public function fields(): array;

    public function hasDetailPage(): bool;
    public function defaultTemplate(): string;

    public function resolveBySlug(int $siteId, int $sectionId, string $slug): ?ObjectRef;
    public function resolveById(int $siteId, int $objectId): ?ObjectRef;
    public function listingQuery(int $siteId, int $sectionId);
    public function adminQuery(int $siteId, int $sectionId);
    public function find(int $siteId, int $objectId): ?Model;

    /** Куда ложатся значения формы: колонки объекта и всё остальное. */
    public function columnValues(array $values): array;
    public function persistValues(Model $model, array $values): void;

    /** Разбирает ли компонент остаток пути после адреса раздела. */
    public function acceptsPathTail(string $tail): bool;

    /** Свой экран объекта в админке, если полей формы недостаточно. */
    public function adminObjectUrl(int $objectId): ?string;
}
```

Три последние группы методов появились, когда компонентам понадобилось больше,
чем «список и карточка»:

- **`columnValues` / `persistValues`** — куда ложатся значения полей. У модуля
  это колонки его таблицы, у компонента из конструктора — отдельная таблица
  значений. Благодаря этому один `ObjectWriter` обслуживает оба вида.
- **`adminQuery`** отличается от `listingQuery` отсутствием фильтра публикации
  (в админке видны черновики) и подтягиванием значений у компонентов
  из конструктора.
- **`acceptsPathTail`** даёт компоненту собственные адреса внутри раздела —
  так живут ЧПУ-фильтры каталога `/katalog/filter/cvet-belyy/`. Ядро не знает,
  что такое фильтр: оно спрашивает компонент, существует ли адрес.
- **`adminObjectUrl`** — ссылка на свой экран объекта. Форма объекта
  универсальна и покрывает поля; цены, остатки и характеристики товара
  полями не выражаются и живут на экране самого модуля.

`EloquentComponent` закрывает всё, кроме `key()`, `label()`, `modelClass()`
и `fields()`, — чтобы модулю не приходилось помнить про фильтр по `site_id`.

> `resolveBySlug` **обязан** фильтровать по сайту и разделу. В легаси этот
> запрос шёл по всей таблице без `Catalogue_ID`, а нужная строка выбиралась
> сортировкой в PHP.

### Необязательные возможности

Всё, без чего компонент работает, объявляется отдельным интерфейсом, а не
методом контракта: иначе каждый новый интерфейс админки или SEO правил бы
все модули и компонент из конструктора.

| Интерфейс | Что даёт |
|---|---|
| `HasAdminIcon` | значок типа страницы в дереве разделов (`adminIcon(): 'store'`); имя — из набора `AdminIcon.vue` |
| `HasAdminObjectScreen` | свои разделы в окне объекта: цены, остатки, характеристики |
| `ProvidesStructuredData` | разметка schema.org объекта |
| `CustomisesObjectCopy` | что обнулить в копии объекта и что докопировать из своих таблиц |
| `EnrichesSuggestions` (`Core\Search`) | фото и цена в выпадающих подсказках поиска |

Не реализует — ядро обходится без этого: строка дерева получает значок
обычной страницы, объект — только форму полей, разметки нет, копируется
одна строка объекта.

### Компонент без внутренних страниц

`hasDetailPage() === false` означает, что объекты выводятся только списком
и адресов у них нет. Так устроена «Текстовая страница»: её блоки выводятся
прямо в разделе.

> В легаси адрес объекта текстовой страницы всё равно открывался и дублировал
> контент раздела для поисковых систем.

## Пример компонента

```php
final class NewsComponent extends EloquentComponent
{
    public function key(): string { return 'news'; }
    public function label(): string { return 'Новости'; }
    public function objectLabel(): string { return 'Новость'; }
    public function modelClass(): string { return NewsItem::class; }

    public function fields(): array
    {
        return [
            'name' => FieldDefinition::make('name', 'Заголовок', FieldKind::String)
                ->required()->searchable()->inListing()->group('main')->sortOrder(10),

            'published_at' => FieldDefinition::make('published_at', 'Дата публикации', FieldKind::DateTime)
                ->inListing()->group('main')->sortOrder(20),

            'body' => FieldDefinition::make('body', 'Полный текст', FieldKind::Html)
                ->searchable()->group('main')->sortOrder(40),

            'is_featured' => FieldDefinition::make('is_featured', 'Выводить на главной', FieldKind::Bool)
                ->filterable()->group('main')->sortOrder(50),
        ];
    }
}
```

## Типы полей

`FieldKind` — встроенные типы объекта:

| Тип | Колонка в таблице компонента | Замечание |
|---|---|---|
| `String` | `string` | |
| `Integer` | `integer` | |
| `Decimal` | `decimal` | |
| `Text` | `text` | многострочный |
| `Html` | `text` | с визуальным редактором |
| `Bool` | `boolean` | |
| `Select` | `string` | значения из `options()` |
| `MultiSelect` | `text` | JSON |
| `Date`, `DateTime` | `date`, `dateTime` | |
| `File`, `Image`, `Gallery` | **нет колонки** | лежат в `media` / `mediables` |
| `Color` | `string` | |
| `ObjectLink` | `unsignedBigInteger` | ссылка на объект другого компонента |
| `Rows` | `text` | JSON: варианты товара, цены по городам |

Файлы и галереи **не занимают колонку** в таблице объекта.

> В легаси колонка мультифайла существовала и всегда содержала пустую строку —
> мусор в каждой строке таблицы.

Тип знает свою колонку хранения, приведение значения в обе стороны и базовые
правила валидации, поэтому генератор миграции модуля может построить таблицу
из объявления полей.

### Объявление поля

```php
FieldDefinition::make('price', 'Цена', FieldKind::Decimal)
    ->required()
    ->hint('Основная цена, без учёта скидок')
    ->default(0)
    ->filterable()
    ->inListing()          // колонка в табличном списке админки
    ->adminOnly()          // публичные формы поле игнорируют
    ->group('prices')      // вкладка формы объекта
    ->sortOrder(30)
    ->rules(['min:0']);
```

`linksTo('contacts')` — для `ObjectLink`: на объекты какого компонента ссылаемся
(например, сотрудник → офис из «Контактов»).

> Легаси хранил всё это в таблице `Field` (729 строк) с колонкой `Format`,
> содержавшей строки вида `10000000:image/*:fs2:inline;use_resize:0;
> resize_width:0;…` — значение приходилось знать наизусть.

### Свои типы полей

Модуль может добавить тип, не трогая ядро:

```php
app(FieldTypeRegistry::class)->register(
    key: 'phone',
    label: 'Телефон',
    storedAs: FieldKind::String,
    rules: ['regex:/^\+7\d{10}$/'],
    cast: fn ($v) => preg_replace('/\D+/', '', (string) $v),
);
```

## Шаблоны компонентов

«Шаблон компонента» — другая вёрстка тех же данных. Один и тот же раздел
новостей выводится списком, плиткой или таблицей; блок может запросить свою
вёрстку, отличную от вёрстки раздела.

### Три контекста

| Контекст | Где применяется |
|---|---|
| `Listing` | список объектов на странице раздела |
| `Detail` | внутренняя страница объекта |
| `Block` | вывод объектов раздела внутри блока |

**Ключ шаблона уникален в пределах контекста, а не компонента.** У списка
и у карточки свои выпадающие списки в админке, и «по умолчанию» там законно
значит разные шаблоны.

```php
$templates->register(ComponentTemplate::listing('news', 'default', 'Список', 'news::listing'));
$templates->register(ComponentTemplate::listing('news', 'tiles', 'Плитки', 'news::listing-tiles'));
$templates->register(ComponentTemplate::block('news', 'default', 'Список ссылок', 'news::block'));
$templates->register(ComponentTemplate::detail('news', 'default', 'Карточка', 'news::detail'));
```

⚠️ **Шаблон списка не годится для блока.** Он печатает собственный заголовок
страницы, и внутри блока с его же заголовком получается два заголовка подряд.
Поэтому `listing()` регистрируется только для контекста `Listing`, а для блока
объявляется отдельный шаблон через `block()`. Этот дефект был реально допущен
и закрыт тестом.

### Выбор шаблона

```
?nc_ctpl не поддерживается (легаси-механика)
      ↓
sections.template_key — выбран в форме раздела
      ↓
'default' в нужном контексте
      ↓
первый зарегистрированный
```

Неизвестный ключ шаблона (модуль выключили после того, как шаблон выбрали
в разделе) не роняет страницу — отдаётся умолчание.

> Легаси делал вариант шаблона отдельной записью в таблице `Class` со ссылкой
> `ClassTemplate` на базовый компонент. Из-за этого на ~50 реальных компонентов
> приходилось 109 записей, и «шаблон» технически был подвидом класса.

### Переменные шаблона

Список (`Listing`, `Block`):

| Переменная | Что это |
|---|---|
| `$section` | `SectionNode` раздела-источника |
| `$items` | пагинатор или коллекция объектов |
| `$component` | сам компонент |
| `$urls` | `UrlBuilder` |
| `$block` | только в контексте блока |

Карточка (`Detail`): `$section`, `$object`, `$component`, `$urls`.

## Запись объектов

Один писатель на все компоненты — `ObjectWriter`. Модуль объявляет таблицу
и поля; слаг, приведение типов, привязку файлов, порядок и транзакцию делает
ядро. Иначе каждый новый модуль переписывал бы одно и то же и однажды забыл бы
про `site_id` или про уникальность слага внутри раздела.

Куда лягут значения, знает сам компонент:

```php
$component->columnValues($values);      // что писать в колонки объекта
$component->persistValues($model, $values);  // что сохранить после save()
$component->adminQuery($siteId, $sectionId); // список для админки, с черновиками
```

Три правила, которые стоят за тестами:

- **Системные колонки форма прислать не может.** `site_id`, `section_id`,
  `slug`, `id` и метки времени считает писатель: раздел и сайт берутся
  из адреса и контекста, иначе подменой поля объект уехал бы в чужой раздел.
- **Слаг не перестраивается вслед за названием** — в отличие от раздела. Адрес
  карточки живёт дольше её заголовка, и правка названия не должна рушить
  внешние ссылки.
- **Слаг пишется всегда**, даже компонентам без внутренней страницы: он входит
  в уникальный ключ `(site_id, section_id, slug)`, а карточка может появиться
  позже.

## Компоненты, созданные в админке

Тип страницы, собранный в конструкторе (`Настройки → Типы страниц`),
реализует тот же контракт `Component`, поэтому реестр, писатель объектов,
резолвер URL, блоки и админка не отличают его от компонента-модуля.

```
component_definitions   ключ, название, есть ли карточка
component_fields        поля: ключ, подпись, тип, флаги, варианты, вкладка
component_objects       объекты: общие колонки (slug, name, публикация, порядок)
component_values        значения: колонка на тип
```

- **Ключ уникален в пределах САЙТА.** «Услуги» могут быть у сотни сайтов, и это
  разные компоненты с разными полями. При совпадении с модульным ключом
  выигрывает модульный — конструктор не может подменить каталог или новости.
- **Значения — в типизированных колонках, не в JSON.** В MySQL нет
  partial-индексов, поэтому индекс по `data->>'$.price'` покрывал бы значения
  всех полей всех компонентов сразу. Плюс числа сравниваются числами:
  в легаси `'9'` было больше `'15000'`.
- **Определения кэшируются сырыми строками** и собираются в модели без запроса:
  иначе каждая страница платила бы двумя `SELECT`'ами за саму возможность иметь
  свои типы страниц.
- **Значения полей поднимаются одним запросом на выборку** (`afterQuery`),
  а не по объекту, и доступны в шаблоне как обычные атрибуты: `$item->price`
  работает одинаково для обоих видов компонентов.
- **Универсальные шаблоны выдаются сразу** — списка, карточки и блока. Иначе
  раздел отдавал бы 500 до тех пор, пока кто-то не напишет Blade. Свой вариант
  из темы или модуля перекрывает их, потому что объявлен раньше.
- **Поле, убранное из формы, удаляется вместе со значениями** каскадом внешнего
  ключа, а не проходом по объектам.
