Compare commits

..

1 Commits
main ... v16

Author SHA1 Message Date
Ali 2056a43cce new branch for frappe v16 2025-07-21 12:28:31 +04:00
14 changed files with 325 additions and 1182 deletions

View File

@ -1,783 +0,0 @@
# 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

View File

@ -6,37 +6,32 @@ def boot_session_handler(bootinfo):
tab_hierarchy_data = {}
for doctype in all_doctypes:
try:
meta = frappe.get_meta(doctype["name"])
meta = frappe.get_meta(doctype["name"])
# Проверяем, есть ли вкладки (Tab Break) в Doctype
if not any(field.fieldtype == "Tab Break" for field in meta.fields):
continue
tabs = []
subtabs = {}
for field in meta.fields:
if field.fieldtype == "Tab Break":
tab_name = field.label
js_parent = getattr(field, 'js_parent_subtab', None) or None
if js_parent:
if js_parent not in subtabs:
subtabs[js_parent] = []
subtabs[js_parent].append(tab_name)
else:
tabs.append(tab_name)
# Добавляем данные для Doctype
tab_hierarchy_data[doctype["name"]] = {
"tabs": tabs,
"subtabs": subtabs
}
except Exception as e:
# Пропускаем DocTypes с ошибками, но не ломаем весь boot
frappe.log_error(f"Error processing subtabs for {doctype['name']}: {str(e)}", "Custom Subtabs Boot Error")
# Проверяем, есть ли вкладки (Tab Break) в Doctype
if not any(field.fieldtype == "Tab Break" for field in meta.fields):
continue
tabs = []
subtabs = {}
for field in meta.fields:
if field.fieldtype == "Tab Break":
tab_name = field.label
js_parent = field.js_parent_subtab or None
if js_parent:
if js_parent not in subtabs:
subtabs[js_parent] = []
subtabs[js_parent].append(tab_name)
else:
tabs.append(tab_name)
# Добавляем данные для Doctype
tab_hierarchy_data[doctype["name"]] = {
"tabs": tabs,
"subtabs": subtabs
}
# Передаём данные на клиентскую сторону через bootinfo
bootinfo.tab_hierarchy = tab_hierarchy_data

View File

@ -25,9 +25,8 @@ app_license = "mit"
# ------------------
# include js, css files in header of desk.html
# Hashed bundles → automatic cache-busting (no nginx 1-year stale-asset issue).
app_include_css = "custom_subtabs.bundle.css"
app_include_js = "custom_subtabs.bundle.js"
app_include_css = "/assets/custom_subtabs/css/custom_subtabs.css"
app_include_js = "/assets/custom_subtabs/js/custom_subtabs.js"
# include js, css files in header of web template
# web_include_css = "/assets/custom_subtabs/css/custom_subtabs.css"

View File

@ -2,7 +2,6 @@ import json
import os
import sys
import shutil
import frappe
from frappe.utils import get_bench_path
def add_field_to_docfield_json():
@ -42,16 +41,6 @@ def add_field_to_docfield_json():
for field in docfield_def.get('fields', []):
if field.get('fieldname') == 'js_parent_subtab':
print("⚠️ Поле 'js_parent_subtab' уже существует в определении DocField.")
# Перезагружаем DocType на случай, если поле есть в JSON, но не в БД
try:
if frappe.db:
frappe.reload_doc('core', 'doctype', 'docfield')
frappe.clear_cache()
print("✅ DocType 'DocField' перезагружен в базе данных и кэш очищен")
except Exception as reload_error:
print(f"⚠️ Не удалось перезагрузить DocType: {str(reload_error)}")
return True
# Добавляем новое поле в список полей после поля 'reqd'
@ -70,21 +59,8 @@ def add_field_to_docfield_json():
# Записываем обновленный JSON обратно в файл
with open(docfield_json_path, 'w', encoding='utf-8') as file:
json.dump(docfield_def, file, indent=1, ensure_ascii=False)
print("✅ Поле 'js_parent_subtab' успешно добавлено в docfield.json")
# Перезагружаем DocType в базе данных
try:
if frappe.db:
frappe.reload_doc('core', 'doctype', 'docfield')
frappe.clear_cache()
print("✅ DocType 'DocField' перезагружен в базе данных и кэш очищен")
else:
print("⚠️ База данных недоступна. Выполните 'bench migrate' и 'bench clear-cache' вручную")
except Exception as reload_error:
print(f"⚠️ Не удалось перезагрузить DocType: {str(reload_error)}")
print("⚠️ Выполните 'bench migrate' и 'bench clear-cache' вручную")
return True
except Exception as e:

View File

@ -1,80 +0,0 @@
.sub-tab-container {
margin: 0 !important;
padding: 0 !important;
display: flex !important;
justify-content: flex-start !important;
border-bottom: 1px solid #ddd !important;
}
.sub-tab-container .sub-tab {
margin: 0 !important;
background: none !important;
border: none !important;
padding: 0 !important;
}
/* Support both <a> and <button> for v15 and v16 compatibility */
.sub-tab-container .sub-tab a,
.sub-tab-container .sub-tab button {
text-decoration: none !important;
display: block !important;
padding: 6px 15px !important;
color: #555 !important;
border: none !important;
font-size: 14px !important;
font-weight: normal !important;
margin: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.sub-tab-container .sub-tab a:hover,
.sub-tab-container .sub-tab button:hover {
color: #000 !important;
}
.sub-tab-container .sub-tab.active a,
.sub-tab-container .sub-tab.active button {
color: #000 !important;
font-weight: 600 !important;
border-bottom: 2px solid #000 !important;
background-color: transparent !important;
border-radius: 0 !important;
}
/* КЛЮЧЕВОЕ ИСПРАВЛЕНИЕ: убрать padding у form-section с субтабами */
.form-section:has(.sub-tab-container) {
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
/* Скрыть пустые section-body */
.form-section .section-body:empty,
.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname="html_vohk"] > span.tooltip-content:only-child) {
display: none !important;
padding: 0 !important;
margin: 0 !important;
min-height: 0 !important;
}
/* Исправить структуру sub-tab-container */
.sub-tab-container.nav {
display: flex;
list-style: none;
padding: 0;
margin: 0;
}
.sub-tab-container.nav > li[style*="display: none"] {
display: none !important;
}
/* Класс для скрытия контейнера субтабов */
.sub-tab-container.subtab-hidden {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
height: 0 !important;
overflow: hidden !important;
}

View File

@ -0,0 +1,104 @@
/* Основной контейнер субтабов */
.sub-tab-container {
margin: 0 !important;
padding: 0 !important;
display: flex !important;
justify-content: flex-start !important;
border-bottom: 1px solid #dee2e6 !important;
background-color: #fff !important;
position: relative !important;
z-index: 1 !important;
}
/* Отдельные субтабы */
.sub-tab-container .sub-tab {
margin: 0 !important;
background: none !important;
border: none !important;
padding: 0 !important;
position: relative !important;
}
/* Ссылки в субтабах */
.sub-tab-container .sub-tab a {
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;
margin: 0 !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
transition: color 0.15s ease-in-out !important;
position: relative !important;
}
.sub-tab-container .sub-tab a:hover {
color: #000 !important; /* Только изменение цвета, без фона */
}
/* Активный субтаб */
.sub-tab-container .sub-tab.active a {
color: #000 !important;
font-weight: 600 !important;
border-bottom: 2px solid #000 !important;
background-color: transparent !important;
border-radius: 0 !important;
margin-bottom: -1px !important; /* Перекрываем нижнюю границу контейнера */
}
/* Интеграция с основными табами ERPNext */
.form-tabs + .sub-tab-container {
border-top: none !important;
margin-top: -1px !important;
}
/* Убираем лишние отступы для форм с субтабами */
.sub-tab-container + .form-layout,
.sub-tab-container ~ .form-layout {
margin-top: 0 !important;
}
/* Адаптивность */
@media (max-width: 768px) {
.sub-tab-container {
flex-wrap: wrap !important;
}
.sub-tab-container .sub-tab a {
padding: 8px 12px !important;
font-size: 13px !important;
}
}
/* Убираем конфликтующие стили Bootstrap */
.sub-tab-container .nav-link {
padding: 0 !important;
border: none !important;
background: none !important;
margin: 0 !important;
border-radius: 0 !important;
}
/* Плавное появление/исчезание */
.sub-tab-container {
opacity: 1 !important;
transition: opacity 0.2s ease-in-out !important;
}
.sub-tab-container[style*="display: none"] {
opacity: 0 !important;
}
/* Убираем лишние границы и тени */
.sub-tab-container .sub-tab,
.sub-tab-container .sub-tab a,
.sub-tab-container .nav-item {
box-shadow: none !important;
border-left: none !important;
border-right: none !important;
border-top: none !important;
}

View File

@ -1,2 +0,0 @@
.sub-tab-container{margin:0!important;padding:0!important;display:flex!important;justify-content:flex-start!important;border-bottom:1px solid #ddd!important}.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:6px 15px!important;color:#555!important;border:none!important;font-size:14px!important;font-weight:400!important;margin:0!important;background:transparent!important;box-shadow:none!important}.sub-tab-container .sub-tab a:hover,.sub-tab-container .sub-tab button:hover{color:#000!important}.sub-tab-container .sub-tab.active a,.sub-tab-container .sub-tab.active button{color:#000!important;font-weight:600!important;border-bottom:2px solid #000!important;background-color:transparent!important;border-radius:0!important}.form-section:has(.sub-tab-container){padding-top:0!important;padding-bottom:0!important;margin-bottom:0!important}.form-section .section-body:empty,.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname="html_vohk"] > span.tooltip-content:only-child){display:none!important;padding:0!important;margin:0!important;min-height:0!important}.sub-tab-container.nav{display:flex;list-style:none;padding:0;margin:0}.sub-tab-container.nav>li[style*="display: none"]{display:none!important}.sub-tab-container.subtab-hidden{display:none!important;visibility:hidden!important;pointer-events:none!important;height:0!important;overflow:hidden!important}
/*# sourceMappingURL=custom_subtabs.bundle.DYDKGLKL.css.map */

View File

@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../../../../../../../tmp/tmp-15812-S7Giyd0UpxCO/custom_subtabs/custom_subtabs/public/css/custom_subtabs.bundle.css"],
"sourcesContent": [".sub-tab-container {\n margin: 0 !important;\n padding: 0 !important;\n display: flex !important;\n justify-content: flex-start !important;\n border-bottom: 1px solid #ddd !important;\n}\n\n.sub-tab-container .sub-tab {\n margin: 0 !important;\n background: none !important;\n border: none !important;\n padding: 0 !important;\n}\n\n/* Support both <a> and <button> for v15 and v16 compatibility */\n.sub-tab-container .sub-tab a,\n.sub-tab-container .sub-tab button {\n text-decoration: none !important;\n display: block !important;\n padding: 6px 15px !important;\n color: #555 !important;\n border: none !important;\n font-size: 14px !important;\n font-weight: normal !important;\n margin: 0 !important;\n background: transparent !important;\n box-shadow: none !important;\n}\n\n.sub-tab-container .sub-tab a:hover,\n.sub-tab-container .sub-tab button:hover {\n color: #000 !important;\n}\n\n.sub-tab-container .sub-tab.active a,\n.sub-tab-container .sub-tab.active button {\n color: #000 !important;\n font-weight: 600 !important;\n border-bottom: 2px solid #000 !important;\n background-color: transparent !important;\n border-radius: 0 !important;\n}\n\n/* \u041A\u041B\u042E\u0427\u0415\u0412\u041E\u0415 \u0418\u0421\u041F\u0420\u0410\u0412\u041B\u0415\u041D\u0418\u0415: \u0443\u0431\u0440\u0430\u0442\u044C padding \u0443 form-section \u0441 \u0441\u0443\u0431\u0442\u0430\u0431\u0430\u043C\u0438 */\n.form-section:has(.sub-tab-container) {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n margin-bottom: 0 !important;\n}\n\n/* \u0421\u043A\u0440\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u0435 section-body */\n.form-section .section-body:empty,\n.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname=\"html_vohk\"] > span.tooltip-content:only-child) {\n display: none !important;\n padding: 0 !important;\n margin: 0 !important;\n min-height: 0 !important;\n}\n\n/* \u0418\u0441\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0443 sub-tab-container */\n.sub-tab-container.nav {\n display: flex;\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.sub-tab-container.nav > li[style*=\"display: none\"] {\n display: none !important;\n}\n\n/* \u041A\u043B\u0430\u0441\u0441 \u0434\u043B\u044F \u0441\u043A\u0440\u044B\u0442\u0438\u044F \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430 \u0441\u0443\u0431\u0442\u0430\u0431\u043E\u0432 */\n.sub-tab-container.subtab-hidden {\n display: none !important;\n visibility: hidden !important;\n pointer-events: none !important;\n height: 0 !important;\n overflow: hidden !important;\n}"],
"mappings": "AAAA,0DAGI,uBACA,qCACA,uCAGJ,4BARA,mBAUI,0BACA,sBAXJ,oBAgBA,iEAEI,+BACA,wBAnBJ,2BAqBI,qBACA,sBACA,yBACA,0BAxBJ,mBA0BI,iCACA,0BAGJ,6EAEI,qBAGJ,+EAEI,qBACA,0BACA,uCACA,uCAxCJ,0BA6CA,sCACI,wBACA,2BACA,0BAIJ,yKAEI,uBAtDJ,uCAyDI,uBAIJ,uBACI,aACA,gBA/DJ,mBAoEA,kDACI,uBAIJ,iCACI,uBACA,4BACA,8BACA,mBACA",
"names": []
}

View File

@ -1,2 +0,0 @@
.sub-tab-container{margin:0!important;padding:0!important;display:flex!important;justify-content:flex-start!important;border-bottom:1px solid #ddd!important}.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:6px 15px!important;color:#555!important;border:none!important;font-size:14px!important;font-weight:400!important;margin:0!important;background:transparent!important;box-shadow:none!important}.sub-tab-container .sub-tab a:hover,.sub-tab-container .sub-tab button:hover{color:#000!important}.sub-tab-container .sub-tab.active a,.sub-tab-container .sub-tab.active button{color:#000!important;font-weight:600!important;border-bottom:2px solid #000!important;background-color:transparent!important;border-radius:0!important}.form-section:has(.sub-tab-container){padding-top:0!important;padding-bottom:0!important;margin-bottom:0!important}.form-section .section-body:empty,.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname="html_vohk"] > span.tooltip-content:only-child){display:none!important;padding:0!important;margin:0!important;min-height:0!important}.sub-tab-container.nav{display:flex;list-style:none;padding:0;margin:0}.sub-tab-container.nav>li[style*="display: none"]{display:none!important}.sub-tab-container.subtab-hidden{display:none!important;visibility:hidden!important;pointer-events:none!important;height:0!important;overflow:hidden!important}
/*# sourceMappingURL=custom_subtabs.bundle.CJRWDWV2.css.map */

View File

@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../../../../../../../tmp/tmp-15812-HYmC3u994rit/custom_subtabs/custom_subtabs/public/css/custom_subtabs.bundle.css"],
"sourcesContent": [".sub-tab-container {\n margin: 0 !important;\n padding: 0 !important;\n display: flex !important;\n justify-content: flex-start !important;\n border-bottom: 1px solid #ddd !important;\n}\n\n.sub-tab-container .sub-tab {\n margin: 0 !important;\n background: none !important;\n border: none !important;\n padding: 0 !important;\n}\n\n/* Support both <a> and <button> for v15 and v16 compatibility */\n.sub-tab-container .sub-tab a,\n.sub-tab-container .sub-tab button {\n text-decoration: none !important;\n display: block !important;\n padding: 6px 15px !important;\n color: #555 !important;\n border: none !important;\n font-size: 14px !important;\n font-weight: normal !important;\n margin: 0 !important;\n background: transparent !important;\n box-shadow: none !important;\n}\n\n.sub-tab-container .sub-tab a:hover,\n.sub-tab-container .sub-tab button:hover {\n color: #000 !important;\n}\n\n.sub-tab-container .sub-tab.active a,\n.sub-tab-container .sub-tab.active button {\n color: #000 !important;\n font-weight: 600 !important;\n border-bottom: 2px solid #000 !important;\n background-color: transparent !important;\n border-radius: 0 !important;\n}\n\n/* \u041A\u041B\u042E\u0427\u0415\u0412\u041E\u0415 \u0418\u0421\u041F\u0420\u0410\u0412\u041B\u0415\u041D\u0418\u0415: \u0443\u0431\u0440\u0430\u0442\u044C padding \u0443 form-section \u0441 \u0441\u0443\u0431\u0442\u0430\u0431\u0430\u043C\u0438 */\n.form-section:has(.sub-tab-container) {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n margin-bottom: 0 !important;\n}\n\n/* \u0421\u043A\u0440\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u0435 section-body */\n.form-section .section-body:empty,\n.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname=\"html_vohk\"] > span.tooltip-content:only-child) {\n display: none !important;\n padding: 0 !important;\n margin: 0 !important;\n min-height: 0 !important;\n}\n\n/* \u0418\u0441\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0443 sub-tab-container */\n.sub-tab-container.nav {\n display: flex;\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.sub-tab-container.nav > li[style*=\"display: none\"] {\n display: none !important;\n}\n\n/* \u041A\u043B\u0430\u0441\u0441 \u0434\u043B\u044F \u0441\u043A\u0440\u044B\u0442\u0438\u044F \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430 \u0441\u0443\u0431\u0442\u0430\u0431\u043E\u0432 */\n.sub-tab-container.subtab-hidden {\n display: none !important;\n visibility: hidden !important;\n pointer-events: none !important;\n height: 0 !important;\n overflow: hidden !important;\n}"],
"mappings": "AAAA,0DAGI,uBACA,qCACA,uCAGJ,4BARA,mBAUI,0BACA,sBAXJ,oBAgBA,iEAEI,+BACA,wBAnBJ,2BAqBI,qBACA,sBACA,yBACA,0BAxBJ,mBA0BI,iCACA,0BAGJ,6EAEI,qBAGJ,+EAEI,qBACA,0BACA,uCACA,uCAxCJ,0BA6CA,sCACI,wBACA,2BACA,0BAIJ,yKAEI,uBAtDJ,uCAyDI,uBAIJ,uBACI,aACA,gBA/DJ,mBAoEA,kDACI,uBAIJ,iCACI,uBACA,4BACA,8BACA,mBACA",
"names": []
}

View File

@ -1,2 +0,0 @@
(()=>{function x(a,t,n){var m;if(!t||!t.length)return;let i=(m=frappe.boot.tab_hierarchy)==null?void 0:m[n];if(!i)return;let e=i.subtabs||{};if(Object.keys(e).length===0||(D(a),t.attr("data-subtabs-processed")==="true"))return;let s=c=>typeof __=="function"?__(c):c,o=t.find("ul.form-tabs > li.nav-item");o.each((c,k)=>{let I=$(k).text().trim(),E=Object.keys(e).find(f=>s(f)===I);if(!E)return;let p=_(k).attr("aria-controls"),d=$(document.getElementById(p));if(!d.length)return;let r=d.find(".form-section.card-section");r.length||(r=d.find(".form-section")),r.length||(r=d.children().first()),r.length||(r=d);let b=r.find("> .sub-tab-container");b.length||(b=$(`<div class="sub-tab-container nav" data-parent-id="${p}"></div>`),r.prepend(b)),e[E].forEach(f=>{let h=o.filter((P,j)=>$(j).text().trim()===s(f));if(!h.length)return;let l=h.clone(!0);l.addClass("sub-tab"),l.attr("data-subtab-name",f),l.attr("data-parent-id",p);let v=_(l);v.off("click"),v.attr("data-target-id",v.attr("aria-controls")),b.append(l),h.remove()})}),t.on("click.customsubtabs",".sub-tab button.nav-link, .sub-tab a.nav-link",function(c){c.preventDefault(),c.stopPropagation(),O(a,t,$(this).closest(".sub-tab"))}),t.attr("data-subtabs-processed","true");let u=t.find("ul.form-tabs > li.nav-item .nav-link.active").first();y(t,[u.attr("aria-controls")])}function _(a){let t=$(a),n=t.find("button.nav-link");return n.length?n:t.find("a.nav-link")}function g(a){let t=_(a);return t.attr("data-target-id")||t.attr("aria-controls")}function L(a,t){let n=[],i=t;for(;i&&i.length;){n.unshift({pane_id:g(i),parent_id:i.attr("data-parent-id")});let e=i.attr("data-parent-id");i=a.find(".sub-tab").filter((s,o)=>g($(o))===e)}return{chain:n,root_id:n.length?n[0].parent_id:null}}function O(a,t,n){let i=L(t,n);if(!i.root_id)return;a.__active_subtab=i;let e=(a.layout.tabs||[]).find(s=>s.wrapper.attr("id")===i.root_id);e?e.set_active():C(a,t,i)}function C(a,t,n){let i=[n.root_id];for(let e of n.chain){let s=document.getElementById(e.pane_id),o=document.getElementById(e.parent_id);!s||!o||(s.classList.add("active","show"),s.classList.remove("hide"),s.previousElementSibling!==o&&o.after(s),i.push(e.pane_id))}y(t,i),B(t,n.chain.map(e=>e.pane_id))}function T(a,t,n){delete a.__active_subtab,y(t,[n]),B(t,[])}function y(a,t){a.find(".sub-tab-container").each(function(){let n=$(this).attr("data-parent-id");$(this).toggleClass("subtab-hidden",!t.includes(n))})}function B(a,t){a.find(".sub-tab").each(function(){let n=t.includes(g($(this)));$(this).toggleClass("active",n),_($(this)).toggleClass("active",n).attr("aria-selected",n?"true":"false")})}function D(a){let t=a.layout&&a.layout.tabs;if(!t||!t.length)return;let n=t[0].constructor;if(n.prototype.__subtabs_patched)return;let i=n.prototype.set_active;n.prototype.set_active=function(){i.apply(this,arguments);let e=this.frm,s=e&&e.layout&&e.layout.wrapper;if(!s||s.attr("data-subtabs-processed")!=="true")return;let o=this.wrapper&&this.wrapper.attr("id");if(!o)return;let u=e.__active_subtab;if(!u||u.root_id!==o){T(e,s,o);return}C(e,s,u)},n.prototype.__subtabs_patched=!0}frappe.ui.form.on("*",{refresh:function(a){setTimeout(()=>{var t;x(a,((t=a.layout)==null?void 0:t.wrapper)||a.$wrapper,a.doctype)},500)}});})();
//# sourceMappingURL=custom_subtabs.bundle.QW446NRW.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,235 +0,0 @@
// Subtabs: nest Frappe form tabs one or two levels deep.
//
// A Tab Break with `js_parent_subtab` set is a subtab. Its nav item is moved out of
// the main tab bar and into a secondary bar rendered inside its parent's pane.
//
// Frappe's tab model has no idea any of this exists. `Tab.set_active()` (form/tab.js)
// strips `show active` off *every* pane except the one it is activating, and
// `Layout.set_tab_as_active()` calls it on every `refresh_sections()` — which Frappe
// runs on every `frm.refresh_field()` and on every value change. So the moment a
// field changed, the open subtab's pane was stripped and its content vanished; the
// content appeared to flash for a fraction of a second and disappear.
//
// The fix is to stop fighting Frappe's model and instead ride on it:
//
// * clicking a subtab registers its top-level ANCESTOR as the active tab, so
// Frappe's own bookkeeping (frm.active_tab_map, the URL hash) stays coherent and
// keeps re-activating the right tab;
// * `Tab.set_active` is wrapped so that once Frappe has finished re-activating that
// ancestor, we re-assert the rest of the chain.
//
// The whole ancestor chain has to be re-asserted, not just the leaf: with two levels
// of nesting, the top pane holds the first subtab bar, the middle pane holds the
// second, and the leaf pane holds the content. All three must be `active show` at
// once — something Frappe's one-tab-at-a-time model will never do on its own.
function setup_custom_subtabs(frm, wrapper, currentDoctype) {
if (!wrapper || !wrapper.length) return;
const hierarchy = frappe.boot.tab_hierarchy?.[currentDoctype];
if (!hierarchy) return;
const subtabs = hierarchy.subtabs || {};
if (Object.keys(subtabs).length === 0) return;
patch_tab_activation(frm);
if (wrapper.attr("data-subtabs-processed") === "true") return;
// tab_hierarchy stores raw (English) labels, while Frappe renders tab labels
// through __(), so the DOM text is already translated. Compare through the
// translation; keep the English label in the internal bookkeeping.
const tr = (s) => (typeof __ === "function" ? __(s) : s);
const tabs = wrapper.find("ul.form-tabs > li.nav-item");
tabs.each((_, tab) => {
const label = $(tab).text().trim();
const parent_key = Object.keys(subtabs).find((k) => tr(k) === label);
if (!parent_key) return;
const parent_pane_id = tab_link(tab).attr("aria-controls");
const parent_pane = $(document.getElementById(parent_pane_id));
if (!parent_pane.length) return;
let section = parent_pane.find(".form-section.card-section");
if (!section.length) section = parent_pane.find(".form-section");
if (!section.length) section = parent_pane.children().first();
if (!section.length) section = parent_pane;
let bar = section.find("> .sub-tab-container");
if (!bar.length) {
bar = $(`<div class="sub-tab-container nav" data-parent-id="${parent_pane_id}"></div>`);
section.prepend(bar);
}
subtabs[parent_key].forEach((child) => {
const original = tabs.filter((__, el) => $(el).text().trim() === tr(child));
if (!original.length) return;
const clone = original.clone(true);
clone.addClass("sub-tab");
clone.attr("data-subtab-name", child);
clone.attr("data-parent-id", parent_pane_id);
// clone(true) copies Frappe's own click handler (tab.js binds set_active on
// the nav-link), which would deactivate the parent pane — the very pane this
// button lives in. Subtab clicks are handled by our delegated handler below.
const link = tab_link(clone);
link.off("click");
link.attr("data-target-id", link.attr("aria-controls"));
bar.append(clone);
original.remove();
});
});
wrapper.on("click.customsubtabs", ".sub-tab button.nav-link, .sub-tab a.nav-link", function (e) {
e.preventDefault();
e.stopPropagation();
activate_subtab(frm, wrapper, $(this).closest(".sub-tab"));
});
wrapper.attr("data-subtabs-processed", "true");
// Whatever tab Frappe already considers active decides which subtab bar shows.
const active = wrapper.find("ul.form-tabs > li.nav-item .nav-link.active").first();
sync_bars(wrapper, [active.attr("aria-controls")]);
}
function tab_link(el) {
const $el = $(el);
const button = $el.find("button.nav-link");
return button.length ? button : $el.find("a.nav-link");
}
function pane_id_of(subtab) {
const link = tab_link(subtab);
return link.attr("data-target-id") || link.attr("aria-controls");
}
// Walk from a subtab up to its top-level tab. Returns the panes to re-assert,
// outermost first, plus the id of the real Frappe tab that roots the chain.
function ancestry(wrapper, subtab) {
const chain = [];
let node = subtab;
while (node && node.length) {
chain.unshift({ pane_id: pane_id_of(node), parent_id: node.attr("data-parent-id") });
const parent_id = node.attr("data-parent-id");
node = wrapper.find(".sub-tab").filter((_, el) => pane_id_of($(el)) === parent_id);
}
return { chain: chain, root_id: chain.length ? chain[0].parent_id : null };
}
function activate_subtab(frm, wrapper, subtab) {
const state = ancestry(wrapper, subtab);
if (!state.root_id) return;
frm.__active_subtab = state;
// Hand the activation to Frappe, naming the top-level ancestor. Frappe records it
// in frm.active_tab_map and the URL hash, so every later refresh re-activates the
// same tab — and our wrapper puts the rest of the chain back on top of it.
const root = (frm.layout.tabs || []).find((t) => t.wrapper.attr("id") === state.root_id);
if (root) {
root.set_active();
} else {
reassert_chain(frm, wrapper, state);
}
}
function reassert_chain(frm, wrapper, state) {
const visible = [state.root_id];
for (const step of state.chain) {
const pane = document.getElementById(step.pane_id);
const parent = document.getElementById(step.parent_id);
if (!pane || !parent) continue;
pane.classList.add("active", "show");
pane.classList.remove("hide");
// A subtab's pane can sit before its parent's in the DOM, depending on
// field_order. Left alone, the subtab bar renders below its own content and
// looks like it disappeared. Keep the pane directly after its parent.
if (pane.previousElementSibling !== parent) {
parent.after(pane);
}
visible.push(step.pane_id);
}
sync_bars(wrapper, visible);
sync_buttons(wrapper, state.chain.map((s) => s.pane_id));
}
function clear_subtabs(frm, wrapper, active_id) {
delete frm.__active_subtab;
sync_bars(wrapper, [active_id]);
sync_buttons(wrapper, []);
}
// A subtab bar belongs to a pane; show it only while that pane is on screen.
function sync_bars(wrapper, visible_pane_ids) {
wrapper.find(".sub-tab-container").each(function () {
const owner = $(this).attr("data-parent-id");
$(this).toggleClass("subtab-hidden", !visible_pane_ids.includes(owner));
});
}
function sync_buttons(wrapper, active_pane_ids) {
wrapper.find(".sub-tab").each(function () {
const on = active_pane_ids.includes(pane_id_of($(this)));
$(this).toggleClass("active", on);
tab_link($(this)).toggleClass("active", on).attr("aria-selected", on ? "true" : "false");
});
}
// Wrap Frappe's Tab.set_active once per session. Everything that re-activates a tab —
// a click, a refresh_sections(), a refresh_field() — funnels through it, so this is
// the one place that can put the subtab chain back after Frappe has torn it down.
function patch_tab_activation(frm) {
const tabs = frm.layout && frm.layout.tabs;
if (!tabs || !tabs.length) return;
const Tab = tabs[0].constructor;
if (Tab.prototype.__subtabs_patched) return;
const set_active = Tab.prototype.set_active;
Tab.prototype.set_active = function () {
set_active.apply(this, arguments);
const form = this.frm;
const wrapper = form && form.layout && form.layout.wrapper;
if (!wrapper || wrapper.attr("data-subtabs-processed") !== "true") return;
const activated = this.wrapper && this.wrapper.attr("id");
if (!activated) return;
const state = form.__active_subtab;
// Activating anything other than the chain's root means the user left the
// subtab — or Frappe restored a different tab. Either way the selection is over.
if (!state || state.root_id !== activated) {
clear_subtabs(form, wrapper, activated);
return;
}
reassert_chain(form, wrapper, state);
};
Tab.prototype.__subtabs_patched = true;
}
frappe.ui.form.on("*", {
refresh: function (frm) {
// The layout is rebuilt after refresh; let it settle before rewiring the bars.
setTimeout(() => {
setup_custom_subtabs(frm, frm.layout?.wrapper || frm.$wrapper, frm.doctype);
}, 500);
},
});

View File

@ -0,0 +1,194 @@
frappe.ui.form.Layout.prototype.setup_tab_events = function () {
setTimeout(() => {
const tabs = this.wrapper.find("ul.form-tabs > li.nav-item");
const currentDoctype = cur_frm.doctype;
const hierarchy = frappe.boot.tab_hierarchy[currentDoctype];
if (!hierarchy) {
return;
}
const subtabs = hierarchy.subtabs || {};
// Function to show tab content
function showTabContent(tabId) {
const tabPane = document.getElementById(tabId);
if (tabPane) {
tabPane.style.display = 'block';
tabPane.classList.add('active', 'show');
}
}
// Function to hide all tab contents
function hideAllTabContents() {
$(".form-tab-content > .tab-pane").each(function () {
$(this).removeClass("active show").css("display", "none");
});
}
// Process subtabs
tabs.each((index, tab) => {
const tabLabel = $(tab).text().trim();
if (subtabs[tabLabel]) {
const tabContentId = $(tab).find("a").attr("aria-controls");
const tabContent = $(`#${tabContentId}`);
if (!tabContent.length) {
return;
}
const possibleSelectors = [
".row.form-section.card-section.visible-section",
".form-section",
".row.form-section",
".card-section",
".visible-section",
".row",
".section",
"div[class*='section']",
"div[class*='form']"
];
let sectionContainer = null;
possibleSelectors.forEach(selector => {
const found = tabContent.find(selector);
if (found.length > 0 && !sectionContainer) {
sectionContainer = found.first();
}
});
if (!sectionContainer || sectionContainer.length === 0) {
sectionContainer = tabContent;
}
// Ищем контейнер субтабов рядом с основными табами, а не внутри контента
const mainTabsContainer = this.wrapper.find("ul.form-tabs");
let subtabContainer = mainTabsContainer.siblings('.sub-tab-container[data-parent-id="' + tabContentId + '"]');
if (!subtabContainer.length) {
// Создаем контейнер субтабов после основных табов
subtabContainer = $("<div class='sub-tab-container nav' data-parent-id='" + tabContentId + "' style='display: none;'></div>");
mainTabsContainer.after(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");
subtabClone.find("a").removeAttr("href");
subtabClone.attr("data-parent-id", tabContentId);
subtabContainer.append(subtabClone);
subtabElement.remove();
}
});
}
});
// Handle main tab activation (исключаем субтабы)
$(".nav-item:not(.sub-tab)").off("click.subtabs").on("click.subtabs", function () {
const clickedTab = $(this);
const tabId = clickedTab.find("a").attr("aria-controls");
hideAllTabContents();
const tabContent = document.querySelector(`#${tabId}`);
if (tabContent) {
$(tabContent).addClass("active show").css("display", "block");
}
$(".nav-item").removeClass("active");
clickedTab.addClass("active");
// Скрываем все контейнеры субтабов
$(".sub-tab-container").css("display", "none");
// Показываем контейнер субтабов для активного таба
$(".sub-tab-container").each(function () {
const containerParentId = $(this).attr("data-parent-id");
if (containerParentId === tabId) {
$(this).css("display", "flex");
$(this).find(".sub-tab").removeClass("active");
}
});
});
// Handle subtab activation (упрощенная логика)
$(document).off("click.subtabs", ".sub-tab").on("click.subtabs", ".sub-tab", function (event) {
event.stopPropagation();
const clickedSubtab = $(this);
const subtabId = clickedSubtab.find("a").attr("aria-controls");
const parentTabId = clickedSubtab.attr("data-parent-id");
// Делаем активным только кликнутый субтаб в этом контейнере
clickedSubtab.siblings(".sub-tab").removeClass("active");
clickedSubtab.addClass("active");
// Скрываем все контенты табов
hideAllTabContents();
// Показываем контент субтаба
const subtabContent = $(`#${subtabId}`);
if (subtabContent.length) {
subtabContent.addClass("active show").css("display", "block");
}
// Оставляем родительский таб активным и видимым
$(".nav-item:not(.sub-tab)").removeClass("active");
const parentTab = $(`.nav-item:not(.sub-tab) a[aria-controls="${parentTabId}"]`).closest('.nav-item');
if (parentTab.length) {
parentTab.addClass("active");
}
// Убеждаемся что контейнер субтабов остается видимым
const subtabContainer = clickedSubtab.closest('.sub-tab-container');
if (subtabContainer.length) {
subtabContainer.css("display", "flex");
}
});
// Проверяем активную вкладку при загрузке страницы
let activeTab = $(".nav-item:not(.sub-tab).active");
// Способ 2: Поиск по активному контенту
if (!activeTab.length) {
const activeContent = $(".form-tab-content > .tab-pane.active");
if (activeContent.length) {
const activeContentId = activeContent.attr('id');
activeTab = $(`.nav-item a[aria-controls="${activeContentId}"]`).closest('.nav-item');
}
}
// Способ 3: Поиск по показанному контенту
if (!activeTab.length) {
const visibleContent = $(".form-tab-content > .tab-pane:visible");
if (visibleContent.length) {
const visibleContentId = visibleContent.first().attr('id');
activeTab = $(`.nav-item a[aria-controls="${visibleContentId}"]`).closest('.nav-item');
}
}
if (activeTab.length) {
const activeTabId = activeTab.find("a").attr("aria-controls");
// Проверяем есть ли субтабы у активной вкладки
$(".sub-tab-container").each(function () {
const containerParentId = $(this).attr("data-parent-id");
if (containerParentId === activeTabId) {
$(this).css("display", "flex");
$(this).find(".sub-tab").removeClass("active");
}
});
}
}, 500);
};