Перейти к содержанию

Расширения без правки ядра

Поля, секции и вкладки под конкретного клиента добавляются к модели из отдельного модуля, файлы ядра не трогаются. Обновление ядра (git pull, новый образ) такой код не затирает: он лежит в папке, которую ядро не меняет и о которой не знает.

Бэкенд и фронтенд устроены одинаково:

Бэкенд Фронтенд
Папка backend/business/ frontend/src/business/
Что подхватывается *_ext.py и пакеты extensions/ — ExtensibleMixin._autodiscover <модуль>/index.ts или index.tsx — import.meta.glob в useModelExtensions; у модулей ядра ещё fara_<модуль>/extensions.ts
Чем расширяет @extend(Model): поля, методы, selection_add; колонки создаёт авто-DDL registerExtension и registerFormTab: секции и вкладки форм, карточки канбана; registerPrintProvider: пункты кнопки «Печать»
Когда загружается На старте сервера При открытии формы или канбана, после модулей ядра

Модуль

frontend/src/business/
└── partner_client/            # имя любое, модули грузятся по алфавиту
    ├── index.ts               # точка входа: side-effect импорты
    └── ViewFormPartner.tsx    # компонент + registerExtension(...)
  • index.ts сборка находит сама, в config/models.ts его прописывать не нужно. Сначала грузятся модули ядра из modelsConfig[model].extensions, затем business-модули по одному в алфавитном порядке папок, поэтому порядок регистрации детерминирован.
  • yarn build проверяет src/business тем же tsc, Docker копирует frontend/ целиком: отдельных шагов сборки нет.
  • В dev-режиме новая папка подхватывается без перезапуска Vite.

Позиции

registerExtension(model, Component, position, fields):

Позиция Где рендерится
before:FormTabs, after:FormTabs Над блоком вкладок и под ним
after:FormSheet Под основным блоком FormSheet
inside:Toolbar Кнопки в тулбаре формы перед переключателем вида — у сохранённой записи, открытой не в попапе. RelatedRecordsButton из components/Form показывает число связанных записей и по клику открывает их список с фильтром — так fara_sales и fara_leads ставят партнёру «Заказы» и «Лиды». Без прав на связанную модель кнопка не рисуется
before:FormTab:<name>, after:FormTab:<name> В начале и в конце контента вкладки <name>
replace:FormTab:<name> Вместо контента вкладки, последний зарегистрированный выигрывает
before:KanbanCard, after:KanbanCard, replace:KanbanCard Карточка канбана, компонент получает { record, model }
provide:FormFields Поставщик полей формы: компонент без разметки, рендерится до загрузки записи, получает { model, layoutFields, onReport } и сообщает onReport({ ready, fields, required }) — какие поля добавить в запрос записи (их нет в разметке) и какие сделать обязательными. Форма грузит запись, когда все поставщики ready (components/Form/formFieldProviders.tsx)
wrap:Form Обёртка разметки формы (внутри <form>): компонент получает { children, layoutFields }, обязан отрисовать children и может поставить своё вокруг — под разметкой, сбоку или вкладкой через FormTabsExtraContext из Form/Layout/FormTabs (вкладки, добавленные на ходу, встают последними). Смонтирована, пока открыта форма, даже когда добавленное стоит во вкладке (неактивную вкладку Mantine прячет через Activity). Первая зарегистрированная — внешняя

Модель '*' — расширение для всех моделей сразу. Так студия добавляет каждой форме зону «Дополнительно»: registerExtension('*', StudioFormFields, 'provide:FormFields') — поля зоны в запрос, registerExtension('*', StudioFormShell, 'wrap:Form') — сама зона вокруг разметки. Иконка в шапке приложения — registerHeaderAction(key, Component) из shared/extensions/headerActions: компонент сам решает, показываться ли. Панель справа на всю высоту под шапкой — <LayoutAside width={…}> из shared/extensions/layoutAside: пока он смонтирован, основная область сужается на ширину панели, а дети рендерятся в неё порталом (контексты React сохраняются); так открывается панель студии.

Новая вкладка — registerFormTab(model, { name, label, icon, component }, fields), она встаёт после вкладок из разметки формы. Имена вкладок формы — в <FormTab name="..."> её Form.tsx (fara_partners/Form.tsx и т.д.).

Несколько расширений на одной позиции идут в порядке регистрации: модули ядра, затем business-модули по алфавиту папок, внутри модуля — по порядку вызовов.

Пример: поля клиента в карточке партнёра

Бэкенд, backend/business/partner_client_ext.py:

from datetime import date

from backend.base.crm.partners.models.partners import Partner
from backend.base.system.core.extensions import extend
from backend.base.system.dotorm.dotorm.fields import Char, Date, Selection


@extend(Partner)
class PartnerClient:
    client_code: str | None = Char(max_length=32, string="Код клиента")
    client_since: date | None = Date(string="Клиент с")
    segment: str | None = Selection(
        options=[("retail", "Розница"), ("b2b", "B2B")],
        string="Сегмент",
    )

Фронтенд, frontend/src/business/partner_client/index.ts:

import './ViewFormPartner';

frontend/src/business/partner_client/ViewFormPartner.tsx:

import { FieldChar } from '@/components/Form/Fields/FieldChar';
import { FieldDate } from '@/components/Form/Fields/FieldDate';
import { FieldSelection } from '@/components/Form/Fields/FieldSelection';
import { FormRow, FormSection } from '@/components/Form/Layout';
import { registerExtension } from '@/shared/extensions';

export function PartnerClientSection() {
  return (
    <FormSection title="Клиент">
      <FormRow cols={3}>
        <FieldChar name="client_code" label="Код клиента" />
        <FieldDate name="client_since" label="Клиент с" />
        <FieldSelection name="segment" label="Сегмент" />
      </FormRow>
    </FormSection>
  );
}

registerExtension('partners', PartnerClientSection, 'before:FormTabs', [
  'client_code',
  'client_since',
  'segment',
]);

Три правила:

  • Список fields обязателен: форма добавляет его к полям разметки в запросе записи и default_values. Без него поля придут без данных и метаданных и не отрисуются.
  • Внутри расширения — конкретные компоненты полей (FieldChar, FieldMany2one, … из components/Form/Fields), а не <Field>: подстановка компонента по типу с сервера работает только для детей <Form>. Обязательность, варианты Selection и связанную модель компоненты берут из контекста формы сами.
  • Значения соседних полей — useFormContext().getValues(). Тип записи с новыми полями объявляется в модуле (interface PartnerClientRecord extends PartnerRecord), править types/records.ts не нужно.

Для всех моделей сразу: кнопка «Печать»

Кнопка «Печать» в тулбаре формы принадлежит ядру (components/Form/PrintButton.tsx), а что печатать, подсказывают модули: registerPrintProvider(key, Provider) из @/shared/extensions/print. Провайдер — невидимый компонент с пропсами { model, recordId, onItems }: он сам делает запросы и проверяет права и отдаёт пункты { key, label, icon?, group?, onSelect } через onItems (ссылку на массив держите стабильной — useMemo). Пока ни один провайдер ничего не отдал, кнопки на форме нет; один пункт — действие сразу по клику, несколько — меню, group даёт заголовок группы. Пример — fara_report_docx/PrintProvider.tsx: DOCX-шаблоны записи и, администратору, пункты «Настроить шаблон».

Регистрация, общая для всех моделей, живёт в extensions.ts в корне модуля ядра (fara_report_docx/extensions.ts) или в index.ts business-модуля: useModelExtensions грузит их вместе с расширениями модели.

Что так не расширяется

Новая модель, пункт меню и маршрут подключаются через config/models.ts; у списков (<List>) точек расширения нет.