custom_subtabs/ARCHITECTURE.md

784 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` для передачи данных на клиент
- Регистрирует патчи для выполнения после миграции
```python
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 и передает их на клиент.
**Алгоритм работы:**
1. Получает список всех DocType в системе
2. Для каждого DocType:
- Получает метаданные через `frappe.get_meta()`
- Ищет поля с `fieldtype == "Tab Break"`
- Для каждой вкладки проверяет наличие `js_parent_subtab`
- Строит структуру `tabs` (основные вкладки) и `subtabs` (подвкладки)
3. Формирует объект `tab_hierarchy_data` вида:
```javascript
{
"Customer": {
"tabs": ["Details", "Settings"],
"subtabs": {
"Settings": ["Email Settings", "Privacy Settings"]
}
},
"Item": {
"tabs": ["Basic", "Pricing"],
"subtabs": {
"Pricing": ["Discounts", "Tax"]
}
}
}
```
4. Передает через `bootinfo.tab_hierarchy` на клиент
**Пример структуры:**
```python
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`.
**Что делает:**
1. Находит файл `frappe/core/doctype/docfield/docfield.json`
2. Добавляет новое поле:
```json
{
"fieldname": "js_parent_subtab",
"fieldtype": "Data",
"label": "JS Parent Subtab",
"description": "Укажите имя родительского таба"
}
```
3. Вставляет поле после поля `reqd` в определении DocField
4. Сохраняет изменения в 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`
```javascript
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('*')`
```javascript
frappe.ui.form.on('*', {
refresh: function(frm) {
setTimeout(() => {
setup_custom_subtabs(frm);
}, 500);
}
});
```
**Зачем два подхода:**
- Первый перехватывает встроенную логику Frappe
- Второй использует event-based подход для совместимости с v16
- Оба могут работать одновременно для максимальной совместимости
##### C. Функция `setup_custom_subtabs(frm)`
**Основная логика:**
1. **Получение данных:**
```javascript
const currentDoctype = frm.doctype;
const hierarchy = frappe.boot.tab_hierarchy?.[currentDoctype];
const subtabs = hierarchy.subtabs || {};
```
2. **Поиск всех вкладок:**
```javascript
const tabs = wrapper.find("ul.form-tabs > li.nav-item");
```
3. **Обработка каждой родительской вкладки:**
```javascript
tabs.each((index, tab) => {
const tabLabel = $(tab).text().trim();
if (subtabs[tabLabel]) {
// Это родительская вкладка с подвкладками
}
});
```
4. **Создание контейнера для подвкладок:**
```javascript
subtabContainer = $("<div class='sub-tab-container nav' data-parent-id='" + tabContentId + "'></div>");
sectionContainer.append(subtabContainer);
```
5. **Перемещение подвкладок:**
```javascript
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)`**
```javascript
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()`**
```javascript
function hideAllTabContents() {
$(".form-tab-content > .tab-pane").each(function () {
$(this).removeClass("active show").css("display", "none");
});
}
```
**Назначение:** Скрывает все содержимое вкладок перед показом выбранной.
**`findParentAndShowSubtabs(currentTabName)`**
```javascript
function findParentAndShowSubtabs(currentTabName) {
// 1. Найти родительскую вкладку для currentTabName
// 2. Показать все подвкладки этой родительской вкладки
// 3. Показать содержимое родительской вкладки
// 4. Рекурсивно обработать родительскую вкладку (если она сама подвкладка)
}
```
**Назначение:** Рекурсивная функция для обработки многоуровневых вложенностей (sub-subtabs).
##### E. Обработчики событий
**Клик по основной вкладке:**
```javascript
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");
}
});
});
```
**Клик по подвкладке:**
```javascript
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`
Стили для визуального оформления подвкладок.
**Структура стилей:**
```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 для горизонтального расположения.
```css
.sub-tab-container .sub-tab {
margin: 0 !important;
background: none !important;
border: none !important;
padding: 0 !important;
}
```
**Назначение:** Сброс стилей для элементов подвкладок.
```css
.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).
```css
.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
1. Администратор открывает DocType через "Customize Form"
2. Добавляет поле Tab Break (например, "Settings")
3. Добавляет еще одно поле Tab Break (например, "Email Settings")
4. В поле "Email Settings" заполняет `JS Parent Subtab` значением "Settings"
**Результат:** Вкладка "Email Settings" станет подвкладкой для "Settings".
#### Шаг 2: Backend обработка (boot session)
При загрузке страницы выполняется `boot_session_handler`:
```python
# Для 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`:
```python
{
"Customer": {
"tabs": ["Details", "Settings", "Contact"],
"subtabs": {
"Settings": ["Email Settings", "Privacy Settings"]
}
}
}
```
#### Шаг 3: Передача данных на клиент
```python
bootinfo.tab_hierarchy = tab_hierarchy_data
```
**Результат:** На клиенте доступен `frappe.boot.tab_hierarchy`.
#### Шаг 4: Frontend обработка (отрисовка формы)
Когда пользователь открывает форму Customer:
1. **Frappe рендерит все вкладки как обычно:**
```html
<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>
```
2. **Вызывается `setup_custom_subtabs()`:**
- Читает `frappe.boot.tab_hierarchy["Customer"]`
- Находит `subtabs["Settings"] = ["Email Settings", "Privacy Settings"]`
- Находит в DOM элементы с текстом "Email Settings" и "Privacy Settings"
- Создает контейнер `.sub-tab-container` внутри контента вкладки "Settings"
- Клонирует элементы подвкладок в контейнер
- Удаляет оригинальные элементы из основного nav
3. **Результат HTML:**
```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"**
1. Скрываются все `.tab-pane`
2. Показывается `#settings-tab-content`
3. Показываются все `.sub-tab` внутри `.sub-tab-container[data-parent-id="settings-tab-content"]`
**Сценарий 2: Клик по подвкладке "Email Settings"**
1. Скрываются все `.tab-pane`
2. Показывается `#email-settings-tab-content`
3. Добавляется класс `active` к подвкладке "Email Settings"
4. Вызывается `findParentAndShowSubtabs("Email Settings")` для показа родительской цепочки
---
## Поддержка многоуровневых вложенностей
Приложение поддерживает sub-subtabs через рекурсивную функцию `findParentAndShowSubtabs`.
**Пример структуры:**
```
Settings (основная вкладка)
├── Email Settings (подвкладка)
│ ├── SMTP Settings (под-подвкладка)
│ └── IMAP Settings (под-подвкладка)
└── Privacy Settings (подвкладка)
```
**Конфигурация:**
```javascript
{
"subtabs": {
"Settings": ["Email Settings", "Privacy Settings"],
"Email Settings": ["SMTP Settings", "IMAP Settings"]
}
}
```
**Как работает:**
1. Пользователь кликает на "SMTP Settings"
2. `findParentAndShowSubtabs("SMTP Settings")` находит родителя "Email Settings"
3. Показывает все подвкладки "Email Settings" (включая "SMTP Settings")
4. Рекурсивно вызывается для "Email Settings"
5. Находит родителя "Settings"
6. Показывает все подвкладки "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>` элемент
**Универсальная реализация:**
```javascript
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 | Вручную (если нужно) |
---
## Настройка и использование
### Установка
1. **Установить приложение:**
```bash
bench get-app https://github.com/your-repo/custom_subtabs
bench --site site_name install-app custom_subtabs
```
2. **Запустить миграцию:**
```bash
bench --site site_name migrate
```
Это выполнит `add_parent_field.py` и добавит поле `js_parent_subtab` в DocField.
3. **Перезапустить bench:**
```bash
bench restart
```
### Использование
1. **Открыть DocType для настройки:**
- Перейти в "Customize Form"
- Выбрать нужный DocType (например, Customer)
2. **Настроить вкладки:**
- Добавить Tab Break с именем "Settings"
- Добавить Tab Break с именем "Email Settings"
- В поле "Email Settings" указать "JS Parent Subtab" = "Settings"
3. **Сохранить и обновить:**
- Сохранить изменения
- Обновить страницу
- Открыть любую форму этого DocType
4. **Результат:**
- Вкладка "Email Settings" появится под "Settings" как подвкладка
- При клике на "Settings" покажутся все подвкладки
---
## Технические детали
### Идентификация элементов
**Tab content ID формируется Frappe:**
```javascript
const tabContentId = tabLink.attr("aria-controls");
// Пример: "customer-settings_tab"
```
**Связь между nav-item и tab-pane:**
```html
<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 родительской вкладки:
```html
<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` и показывает их.
### Таймауты
```javascript
setTimeout(() => {
setup_custom_subtabs(frm);
}, 500);
```
**Зачем:** Frappe рендерит вкладки асинхронно. Таймаут дает время для завершения рендеринга перед обработкой подвкладок.
### Event delegation
```javascript
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`:
```css
.sub-tab-container .sub-tab.active button {
color: #ff5733 !important; /* Красный цвет для активной подвкладки */
}
```
### Поддержка вертикальных вкладок
Изменить `.sub-tab-container`:
```css
.sub-tab-container {
flex-direction: column !important;
}
```
### Иконки для подвкладок
Добавить логику в JS:
```javascript
const iconMapping = {
"Email Settings": "fa-envelope",
"Privacy Settings": "fa-lock"
};
subtabClone.prepend(`<i class="fa ${iconMapping[subtab]}"></i>`);
```
---
## Отладка
### Включение логов
Скрипт содержит множество `console.log()` для отладки:
```javascript
console.log("Hierarchy for doctype:", hierarchy);
console.log("Processing parent tab:", tabLabel);
console.log("Looking for subtab:", subtab, "found:", subtabElement.length);
```
**Открыть консоль браузера (F12)** и проверить логи при загрузке формы.
### Проверка tab_hierarchy
В консоли браузера:
```javascript
console.log(frappe.boot.tab_hierarchy);
```
**Ожидаемый результат:**
```javascript
{
"Customer": {
"tabs": ["Details", "Settings"],
"subtabs": {
"Settings": ["Email Settings", "Privacy Settings"]
}
}
}
```
### Проверка DOM структуры
```javascript
// Проверить все контейнеры подвкладок
$(".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