32 KiB
Custom Subtabs - Подробная документация по архитектуре
Обзор приложения
Custom Subtabs — это приложение для Frappe Framework, которое добавляет возможность создания вложенных вкладок (подвкладок) в формах DocType. Приложение позволяет организовать вкладки иерархически, когда одна вкладка может содержать другие подвкладки.
Основные возможности:
- Создание иерархии вкладок (parent-child отношения)
- Автоматическое перемещение подвкладок под родительскую вкладку
- Визуальное отображение подвкладок с отдельным стилем
- Поддержка Frappe v15 и v16
- Динамическая загрузка конфигурации через boot session
Архитектура системы
Общая схема работы
┌─────────────────────────────────────────────────────────────────┐
│ Frappe Backend │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ boot_session_handler │ │
│ │ - Сканирует все DocType │ │
│ │ - Находит Tab Break поля с js_parent_subtab │ │
│ │ - Создает tab_hierarchy структуру │ │
│ │ - Передает данные на клиент через bootinfo │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ add_parent_field.py (patch) │ │
│ │ - Модифицирует docfield.json │ │
│ │ - Добавляет поле js_parent_subtab в DocField │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓
frappe.boot.tab_hierarchy
↓
┌─────────────────────────────────────────────────────────────────┐
│ Frappe Frontend (Browser) │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ custom_subtabs.js │ │
│ │ - Переопределяет setup_tab_events() │ │
│ │ - Обрабатывает form refresh события │ │
│ │ - Читает tab_hierarchy из frappe.boot │ │
│ │ - Перемещает подвкладки в контейнеры │ │
│ │ - Обрабатывает клики по вкладкам │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ custom_subtabs.css │ │
│ │ - Стилизует sub-tab-container │ │
│ │ - Определяет внешний вид подвкладок │ │
│ │ - Активное состояние подвкладок │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Компоненты приложения
1. Backend компоненты
1.1 hooks.py
Расположение: custom_subtabs/hooks.py
Основной файл конфигурации приложения:
- Подключает CSS и JS файлы к desk.html
- Регистрирует
boot_session_handlerдля передачи данных на клиент - Регистрирует патчи для выполнения после миграции
app_include_css = "/assets/custom_subtabs/css/custom_subtabs.css"
app_include_js = "/assets/custom_subtabs/js/custom_subtabs.js"
boot_session = "custom_subtabs.custom_subtabs.boot_session_handler"
after_migrate = ["custom_subtabs.patches.add_parent_field.add_field_to_docfield_json"]
1.2 boot_session_handler
Расположение: custom_subtabs/custom_subtabs/__init__.py
Назначение: Подготавливает данные о структуре вкладок для всех DocType и передает их на клиент.
Алгоритм работы:
- Получает список всех DocType в системе
- Для каждого DocType:
- Получает метаданные через
frappe.get_meta() - Ищет поля с
fieldtype == "Tab Break" - Для каждой вкладки проверяет наличие
js_parent_subtab - Строит структуру
tabs(основные вкладки) иsubtabs(подвкладки)
- Получает метаданные через
- Формирует объект
tab_hierarchy_dataвида:{ "Customer": { "tabs": ["Details", "Settings"], "subtabs": { "Settings": ["Email Settings", "Privacy Settings"] } }, "Item": { "tabs": ["Basic", "Pricing"], "subtabs": { "Pricing": ["Discounts", "Tax"] } } } - Передает через
bootinfo.tab_hierarchyна клиент
Пример структуры:
tab_hierarchy_data = {
"Customer": {
"tabs": ["Details", "Settings", "Contact"],
"subtabs": {
"Settings": ["Email Settings", "Privacy Settings"]
}
}
}
1.3 add_parent_field.py (Patch)
Расположение: custom_subtabs/patches/add_parent_field.py
Назначение: Модифицирует основной DocField DocType, добавляя поле js_parent_subtab.
Что делает:
- Находит файл
frappe/core/doctype/docfield/docfield.json - Добавляет новое поле:
{ "fieldname": "js_parent_subtab", "fieldtype": "Data", "label": "JS Parent Subtab", "description": "Укажите имя родительского таба" } - Вставляет поле после поля
reqdв определении DocField - Сохраняет изменения в JSON файл
Зачем это нужно: После выполнения этого патча, в интерфейсе настройки любого DocType, при добавлении поля Tab Break, появится дополнительное поле "JS Parent Subtab", где можно указать имя родительской вкладки.
1.4 custom_field.py (Override)
Расположение: custom_subtabs/overrides/custom_field.py
Статус: Закомментирован / не используется
Исходно был создан для автоматической настройки вложенных вкладок через override CustomField, но в текущей реализации не используется.
1.5 utils.py
Расположение: custom_subtabs/utils.py
Статус: Содержит функцию process_nested_tabs, которая не используется в текущей реализации
1.6 delete_custom_field.py
Расположение: custom_subtabs/delete_custom_field.py
Утилита для удаления Custom Field DocField-js_parent_subtab (если он был создан через Custom Field вместо модификации JSON).
2. Frontend компоненты
2.1 custom_subtabs.js
Расположение: custom_subtabs/public/js/custom_subtabs.js
Основной JavaScript файл, реализующий логику подвкладок на клиенте.
Структура кода:
A. Переопределение frappe.ui.form.Layout.prototype.setup_tab_events
const original_setup_tab_events = frappe.ui.form.Layout.prototype.setup_tab_events;
frappe.ui.form.Layout.prototype.setup_tab_events = function () {
// Вызов оригинальной функции
original_setup_tab_events.call(this);
setTimeout(() => {
// Логика обработки подвкладок
}, 500);
};
Зачем переопределяется: Frappe вызывает setup_tab_events() при рендеринге формы. Мы перехватываем этот вызов, чтобы добавить свою логику после стандартной обработки вкладок.
B. Альтернативный подход через frappe.ui.form.on('*')
frappe.ui.form.on('*', {
refresh: function(frm) {
setTimeout(() => {
setup_custom_subtabs(frm);
}, 500);
}
});
Зачем два подхода:
- Первый перехватывает встроенную логику Frappe
- Второй использует event-based подход для совместимости с v16
- Оба могут работать одновременно для максимальной совместимости
C. Функция setup_custom_subtabs(frm)
Основная логика:
-
Получение данных:
const currentDoctype = frm.doctype; const hierarchy = frappe.boot.tab_hierarchy?.[currentDoctype]; const subtabs = hierarchy.subtabs || {}; -
Поиск всех вкладок:
const tabs = wrapper.find("ul.form-tabs > li.nav-item"); -
Обработка каждой родительской вкладки:
tabs.each((index, tab) => { const tabLabel = $(tab).text().trim(); if (subtabs[tabLabel]) { // Это родительская вкладка с подвкладками } }); -
Создание контейнера для подвкладок:
subtabContainer = $("<div class='sub-tab-container nav' data-parent-id='" + tabContentId + "'></div>"); sectionContainer.append(subtabContainer); -
Перемещение подвкладок:
subtabs[tabLabel].forEach((subtab) => { const subtabElement = tabs.filter((_, el) => $(el).text().trim() === subtab); if (subtabElement.length) { const subtabClone = subtabElement.clone(); subtabClone.addClass("sub-tab"); subtabContainer.append(subtabClone); subtabElement.remove(); // Удаляем из основного nav } });
D. Helper функции
getTabLink(tabElement)
function getTabLink(tabElement) {
const $tab = $(tabElement);
let link = $tab.find("button.nav-link");
if (!link.length) {
link = $tab.find("a.nav-link");
}
return link;
}
Назначение: Поддержка v15 (используются <a>) и v16 (используются <button>).
hideAllTabContents()
function hideAllTabContents() {
$(".form-tab-content > .tab-pane").each(function () {
$(this).removeClass("active show").css("display", "none");
});
}
Назначение: Скрывает все содержимое вкладок перед показом выбранной.
findParentAndShowSubtabs(currentTabName)
function findParentAndShowSubtabs(currentTabName) {
// 1. Найти родительскую вкладку для currentTabName
// 2. Показать все подвкладки этой родительской вкладки
// 3. Показать содержимое родительской вкладки
// 4. Рекурсивно обработать родительскую вкладку (если она сама подвкладка)
}
Назначение: Рекурсивная функция для обработки многоуровневых вложенностей (sub-subtabs).
E. Обработчики событий
Клик по основной вкладке:
wrapper.off("click", ".nav-item").on("click", ".nav-item", function () {
const clickedTab = $(this);
const tabId = getTabLink(clickedTab).attr("aria-controls");
// 1. Скрыть всё содержимое
hideAllTabContents();
// 2. Показать содержимое выбранной вкладки
$(tabContent).addClass("active show").css("display", "block");
// 3. Показать/скрыть подвкладки в зависимости от родительской вкладки
$(".sub-tab-container").each(function () {
if ($(this).attr("data-parent-id") === tabId) {
$(this).find(".sub-tab").css("display", "block");
} else {
$(this).find(".sub-tab").css("display", "none");
}
});
});
Клик по подвкладке:
wrapper.off("click", ".sub-tab").on("click", ".sub-tab", function (event) {
event.stopPropagation(); // Не вызывать обработчик родительской вкладки
const clickedSubtab = $(this);
const subtabId = getTabLink(clickedSubtab).attr("aria-controls");
const subtabName = clickedSubtab.text().trim();
// 1. Убрать active с других подвкладок
$(".sub-tab").removeClass("active");
clickedSubtab.addClass("active");
// 2. Скрыть всё содержимое
hideAllTabContents();
// 3. Показать содержимое выбранной подвкладки
$(subtabContent).addClass("active show").css("display", "block");
// 4. Показать родительскую цепочку
findParentAndShowSubtabs(subtabName);
});
2.2 custom_subtabs.css
Расположение: custom_subtabs/public/css/custom_subtabs.css
Стили для визуального оформления подвкладок.
Структура стилей:
.sub-tab-container {
margin: 0 !important;
padding: 0 !important;
display: flex !important;
justify-content: flex-start !important;
border-bottom: 1px solid #ddd !important;
}
Назначение: Контейнер для подвкладок, отображается как flexbox для горизонтального расположения.
.sub-tab-container .sub-tab {
margin: 0 !important;
background: none !important;
border: none !important;
padding: 0 !important;
}
Назначение: Сброс стилей для элементов подвкладок.
.sub-tab-container .sub-tab a,
.sub-tab-container .sub-tab button {
text-decoration: none !important;
display: block !important;
padding: 10px 15px !important;
color: #555 !important;
border: none !important;
font-size: 14px !important;
font-weight: normal !important;
}
Назначение: Стилизация ссылок и кнопок внутри подвкладок (поддержка v15/v16).
.sub-tab-container .sub-tab.active a,
.sub-tab-container .sub-tab.active button {
color: #000 !important;
font-weight: 600 !important;
border-bottom: 1px solid #000 !important;
background-color: transparent !important;
}
Назначение: Стилизация активной подвкладки (черный текст, жирный шрифт, черная линия снизу).
Как работают подвкладки (Subtabs)
Пошаговый процесс
Шаг 1: Настройка в DocType
- Администратор открывает DocType через "Customize Form"
- Добавляет поле Tab Break (например, "Settings")
- Добавляет еще одно поле Tab Break (например, "Email Settings")
- В поле "Email Settings" заполняет
JS Parent Subtabзначением "Settings"
Результат: Вкладка "Email Settings" станет подвкладкой для "Settings".
Шаг 2: Backend обработка (boot session)
При загрузке страницы выполняется boot_session_handler:
# Для Customer DocType
meta = frappe.get_meta("Customer")
for field in meta.fields:
if field.fieldtype == "Tab Break":
tab_name = field.label # "Email Settings"
js_parent = field.js_parent_subtab # "Settings"
if js_parent:
subtabs["Settings"].append("Email Settings")
Результат: Создается структура tab_hierarchy:
{
"Customer": {
"tabs": ["Details", "Settings", "Contact"],
"subtabs": {
"Settings": ["Email Settings", "Privacy Settings"]
}
}
}
Шаг 3: Передача данных на клиент
bootinfo.tab_hierarchy = tab_hierarchy_data
Результат: На клиенте доступен frappe.boot.tab_hierarchy.
Шаг 4: Frontend обработка (отрисовка формы)
Когда пользователь открывает форму Customer:
-
Frappe рендерит все вкладки как обычно:
<ul class="nav form-tabs"> <li class="nav-item">Details</li> <li class="nav-item">Settings</li> <li class="nav-item">Email Settings</li> <!-- Будет перемещена --> <li class="nav-item">Privacy Settings</li> <!-- Будет перемещена --> <li class="nav-item">Contact</li> </ul> -
Вызывается
setup_custom_subtabs():- Читает
frappe.boot.tab_hierarchy["Customer"] - Находит
subtabs["Settings"] = ["Email Settings", "Privacy Settings"] - Находит в DOM элементы с текстом "Email Settings" и "Privacy Settings"
- Создает контейнер
.sub-tab-containerвнутри контента вкладки "Settings" - Клонирует элементы подвкладок в контейнер
- Удаляет оригинальные элементы из основного nav
- Читает
-
Результат HTML:
<ul class="nav form-tabs"> <li class="nav-item">Details</li> <li class="nav-item">Settings</li> <li class="nav-item">Contact</li> </ul> <div class="tab-pane" id="settings-tab-content"> <div class="sub-tab-container"> <li class="nav-item sub-tab">Email Settings</li> <li class="nav-item sub-tab">Privacy Settings</li> </div> <!-- Содержимое вкладки Settings --> </div>
Шаг 5: Взаимодействие пользователя
Сценарий 1: Клик по основной вкладке "Settings"
- Скрываются все
.tab-pane - Показывается
#settings-tab-content - Показываются все
.sub-tabвнутри.sub-tab-container[data-parent-id="settings-tab-content"]
Сценарий 2: Клик по подвкладке "Email Settings"
- Скрываются все
.tab-pane - Показывается
#email-settings-tab-content - Добавляется класс
activeк подвкладке "Email Settings" - Вызывается
findParentAndShowSubtabs("Email Settings")для показа родительской цепочки
Поддержка многоуровневых вложенностей
Приложение поддерживает sub-subtabs через рекурсивную функцию findParentAndShowSubtabs.
Пример структуры:
Settings (основная вкладка)
├── Email Settings (подвкладка)
│ ├── SMTP Settings (под-подвкладка)
│ └── IMAP Settings (под-подвкладка)
└── Privacy Settings (подвкладка)
Конфигурация:
{
"subtabs": {
"Settings": ["Email Settings", "Privacy Settings"],
"Email Settings": ["SMTP Settings", "IMAP Settings"]
}
}
Как работает:
- Пользователь кликает на "SMTP Settings"
findParentAndShowSubtabs("SMTP Settings")находит родителя "Email Settings"- Показывает все подвкладки "Email Settings" (включая "SMTP Settings")
- Рекурсивно вызывается для "Email Settings"
- Находит родителя "Settings"
- Показывает все подвкладки "Settings" (включая "Email Settings")
Результат: Пользователь видит всю цепочку: Settings > Email Settings > SMTP Settings
Совместимость с версиями Frappe
Frappe v15
- Использует
<a class="nav-link">для вкладок - Функция
getTabLink()находит<a>элемент
Frappe v16
- Использует
<button class="nav-link">для вкладок - Функция
getTabLink()находит<button>элемент
Универсальная реализация:
function getTabLink(tabElement) {
const $tab = $(tabElement);
let link = $tab.find("button.nav-link");
if (!link.length) {
link = $tab.find("a.nav-link");
}
return link;
}
Ключевые файлы и их назначение
| Файл | Назначение | Когда выполняется |
|---|---|---|
hooks.py |
Конфигурация приложения | При загрузке Frappe |
boot_session_handler |
Создание tab_hierarchy | При каждом входе пользователя |
add_parent_field.py |
Добавление поля js_parent_subtab | После миграции (once) |
custom_subtabs.js |
Логика подвкладок на клиенте | При загрузке каждой страницы |
custom_subtabs.css |
Стили подвкладок | При загрузке каждой страницы |
delete_custom_field.py |
Очистка Custom Field | Вручную (если нужно) |
Настройка и использование
Установка
-
Установить приложение:
bench get-app https://github.com/your-repo/custom_subtabs bench --site site_name install-app custom_subtabs -
Запустить миграцию:
bench --site site_name migrateЭто выполнит
add_parent_field.pyи добавит полеjs_parent_subtabв DocField. -
Перезапустить bench:
bench restart
Использование
-
Открыть DocType для настройки:
- Перейти в "Customize Form"
- Выбрать нужный DocType (например, Customer)
-
Настроить вкладки:
- Добавить Tab Break с именем "Settings"
- Добавить Tab Break с именем "Email Settings"
- В поле "Email Settings" указать "JS Parent Subtab" = "Settings"
-
Сохранить и обновить:
- Сохранить изменения
- Обновить страницу
- Открыть любую форму этого DocType
-
Результат:
- Вкладка "Email Settings" появится под "Settings" как подвкладка
- При клике на "Settings" покажутся все подвкладки
Технические детали
Идентификация элементов
Tab content ID формируется Frappe:
const tabContentId = tabLink.attr("aria-controls");
// Пример: "customer-settings_tab"
Связь между nav-item и tab-pane:
<li class="nav-item">
<button class="nav-link" aria-controls="customer-settings_tab">Settings</button>
</li>
<div class="tab-pane" id="customer-settings_tab">
<!-- Content -->
</div>
Атрибут data-parent-id
Контейнер подвкладок хранит ID родительской вкладки:
<div class="sub-tab-container" data-parent-id="customer-settings_tab">
<li class="nav-item sub-tab" data-parent-id="customer-settings_tab">Email Settings</li>
</div>
Использование:
Когда пользователь кликает на основную вкладку, скрипт ищет все .sub-tab-container с соответствующим data-parent-id и показывает их.
Таймауты
setTimeout(() => {
setup_custom_subtabs(frm);
}, 500);
Зачем: Frappe рендерит вкладки асинхронно. Таймаут дает время для завершения рендеринга перед обработкой подвкладок.
Event delegation
wrapper.off("click", ".sub-tab").on("click", ".sub-tab", function () { ... });
Зачем off() перед on(): Предотвращает дублирование обработчиков при повторном вызове функции (например, при refresh формы).
Ограничения и известные проблемы
1. Имена вкладок должны быть уникальными
Скрипт использует text().trim() для поиска вкладок. Если две вкладки имеют одинаковое имя, возможны конфликты.
2. Производительность при большом количестве DocType
boot_session_handler сканирует все DocType при каждом входе пользователя. При большом количестве DocType (>1000) это может замедлить загрузку.
Решение: Добавить кэширование или фильтрацию только нужных DocType.
3. Язык интерфейса
Имена вкладок должны совпадать точно, включая локализацию. Если вкладка переведена, нужно использовать переведенное имя в js_parent_subtab.
4. Динамическое добавление вкладок
Если вкладки добавляются динамически через JS после загрузки формы, подвкладки могут не обработаться.
Решение: Вызвать setup_custom_subtabs(frm) вручную после добавления вкладок.
Расширение функциональности
Добавление новых стилей
Отредактировать custom_subtabs/public/css/custom_subtabs.css:
.sub-tab-container .sub-tab.active button {
color: #ff5733 !important; /* Красный цвет для активной подвкладки */
}
Поддержка вертикальных вкладок
Изменить .sub-tab-container:
.sub-tab-container {
flex-direction: column !important;
}
Иконки для подвкладок
Добавить логику в JS:
const iconMapping = {
"Email Settings": "fa-envelope",
"Privacy Settings": "fa-lock"
};
subtabClone.prepend(`<i class="fa ${iconMapping[subtab]}"></i>`);
Отладка
Включение логов
Скрипт содержит множество console.log() для отладки:
console.log("Hierarchy for doctype:", hierarchy);
console.log("Processing parent tab:", tabLabel);
console.log("Looking for subtab:", subtab, "found:", subtabElement.length);
Открыть консоль браузера (F12) и проверить логи при загрузке формы.
Проверка tab_hierarchy
В консоли браузера:
console.log(frappe.boot.tab_hierarchy);
Ожидаемый результат:
{
"Customer": {
"tabs": ["Details", "Settings"],
"subtabs": {
"Settings": ["Email Settings", "Privacy Settings"]
}
}
}
Проверка DOM структуры
// Проверить все контейнеры подвкладок
$(".sub-tab-container").each(function() {
console.log("Parent ID:", $(this).attr("data-parent-id"));
console.log("Subtabs count:", $(this).find(".sub-tab").length);
});
Заключение
Custom Subtabs — это полноценное приложение для организации иерархических вкладок в Frappe. Оно использует:
- Backend: Python для генерации конфигурации и модификации DocField
- Frontend: JavaScript для динамической перестройки DOM и обработки событий
- CSS: Для стилизации подвкладок
Приложение полностью интегрируется с Frappe Framework и поддерживает версии v15 и v16.
Контакты
- Автор: Jey Soft
- Email: info@jeyerp.az
- Лицензия: MIT