Расширения без правки ядра¶
Поля, секции и вкладки под конкретного клиента добавляются к модели из отдельного модуля, файлы ядра не трогаются. Обновление ядра (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:
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>) точек расширения нет.