Compare commits

...

13 Commits
v16 ... main

Author SHA1 Message Date
Ali 63476b39aa fix: keep subtabs open when Frappe re-activates a tab
Frappe's tab model does not know subtabs exist. Tab.set_active() strips
`show active` off every pane except the one it activates, and Frappe calls it on
every refresh_sections() — which runs on every refresh_field() and every value
change. So editing any field wiped the open subtab: its content appeared for a
fraction of a second and vanished.

Clicking a subtab now registers its top-level ancestor as the active tab, so
Frappe's own bookkeeping stays coherent and keeps re-activating the right tab, and
Tab.set_active is wrapped to re-assert the rest of the chain once Frappe is done.

The whole ancestor chain has to be restored, not just the leaf: subtabs nest two
deep in several declarations, where the top pane holds the first subtab bar, the
middle pane holds the second and the leaf holds the content — three panes active
at once, which Frappe's one-tab-at-a-time model will never do on its own.

This replaces the MutationObserver, which was fighting the same battle from the
wrong end: it could not tell a genuine tab switch from a refresh re-asserting the
tab already open.

Verified: 70 subtabs across 11 doctypes stay visible through refresh_field() and
refresh_sections().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:22:07 +00:00
Ali 7d9fb69f3b perf(assets): serve desk js/css as hashed bundles
Rename custom_subtabs.js/.css to .bundle.js/.bundle.css so esbuild emits
content-hashed filenames → automatic cache-busting (no more nginx 1-year
stale-asset issue on edits). setup_custom_subtabs is defined and called
within the file (verified self-contained), so esbuild's IIFE wrap is safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:28:31 +00:00
Ali c5e637fef2 fix: keep subtab bar above content when subtab panes precede parent
The subtab bar lives inside the parent tab pane. When the doctype's field_order
places the subtab content panes BEFORE the parent pane (e.g. E-Taxes Settings
"Data": customers_tab/suppliers_tab come before data_tab), the activated content
renders above the bar and pushes it off-screen — looking like the subtabs vanished.
Parents whose children come after them (e.g. "Mappings") were unaffected.

On subtab click, move the active content pane to sit right after the parent pane
so the bar always renders above it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:53:30 +00:00
Ali 5719f9017e fix: keep subtab bar visible on subtab click (drop cloned Frappe handler)
clone(true) copied Frappe's per-nav-link click handler (tab.js -> set_active)
onto the subtab buttons, so every subtab click also ran set_active(), which
deactivates the parent tab pane that physically contains the subtab buttons.
Our handler then had to undo it by reconstructing the parent pane id from the
(translated) label — fragile.

Drop the copied handler on cloned subtab links, and restore the parent pane
by its reliable data-parent-id instead of label string-building.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:46:45 +00:00
Ali a915f82aed fix: match subtabs by translated label, not raw English key
tab_hierarchy stores raw (English) Tab Break labels, but Frappe renders
tab text through __(), so under AZ/RU the DOM text never equals the
hierarchy key and subtabs silently stay flat (e.g. E-Taxes Settings,
Bank Integration Profile). Doctypes with native-AZ labels were unaffected.

Compare DOM tab text against tr(key) at the matching boundary; keep all
internal bookkeeping (childToParentMap, data-subtab-name) in English so
clicks and parent-chain reveal stay consistent across languages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:29:37 +00:00
Ali 174fbe8f29 subtab bug fix 2026-01-12 19:14:24 +04:00
Ali 5cf9e982dd subtab bug fix 2026-01-12 18:57:01 +04:00
Ali 994d44cdaf subtab bug fix 2026-01-12 18:51:42 +04:00
Ali 9d57da7061 subtab bug fix 2026-01-12 18:45:00 +04:00
Ali 751de46b7b fix: add error handling and safe attribute access in boot_session_handler
Problem:
- boot_session_handler was failing silently for some DocTypes
- Direct access to field.js_parent_subtab caused AttributeError
- DocTypes with errors prevented entire hierarchy from being built

Solution:
- Added try-except around DocType processing loop
- Changed field.js_parent_subtab to getattr(field, 'js_parent_subtab', None)
- Errors are logged but don't break the entire boot process

Result:
- All DocTypes with subtabs now properly included in tab_hierarchy
- "Declaration of value added tax" and similar DocTypes now work correctly

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 18:38:19 +04:00
Ali ff3924f0fd fix: hide entire subtab container instead of just subtabs
Problem:
- When switching to a tab without subtabs, the subtab text was hidden
- But the container remained visible with border and took up space
- Invisible buttons were still clickable and could switch to previous subtabs
- Cursor changed to pointer over the invisible container

Solution:
- Changed logic to hide/show entire .sub-tab-container instead of individual .sub-tab elements
- Now container is completely removed from layout when not active
- Prevents phantom clicks and visual artifacts

Changed in custom_subtabs.js (lines 192, 194):
- Before: $(this).find(".sub-tab").show/hide()
- After: $(this).show/hide()

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 18:29:20 +04:00
Ali 2a97ca39bc fix: add DocType reload and cache clear to patch
- Added frappe.reload_doc() after modifying docfield.json
- Added frappe.clear_cache() to ensure changes are applied
- Now patch automatically syncs JSON changes to database
- Fixed issue where js_parent_subtab field existed in JSON but not in DB

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 17:33:26 +04:00
Ali 76b178b93a fixed subtabs for v16 2025-12-09 15:52:37 +04:00
14 changed files with 1182 additions and 243 deletions

783
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,783 @@
# 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,6 +6,7 @@ def boot_session_handler(bootinfo):
tab_hierarchy_data = {}
for doctype in all_doctypes:
try:
meta = frappe.get_meta(doctype["name"])
# Проверяем, есть ли вкладки (Tab Break) в Doctype
@ -18,7 +19,7 @@ def boot_session_handler(bootinfo):
for field in meta.fields:
if field.fieldtype == "Tab Break":
tab_name = field.label
js_parent = field.js_parent_subtab or None
js_parent = getattr(field, 'js_parent_subtab', None) or None
if js_parent:
if js_parent not in subtabs:
@ -32,6 +33,10 @@ def boot_session_handler(bootinfo):
"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")
continue
# Передаём данные на клиентскую сторону через bootinfo
bootinfo.tab_hierarchy = tab_hierarchy_data

View File

@ -25,8 +25,9 @@ app_license = "mit"
# ------------------
# include js, css files in header of desk.html
app_include_css = "/assets/custom_subtabs/css/custom_subtabs.css"
app_include_js = "/assets/custom_subtabs/js/custom_subtabs.js"
# 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"
# include js, css files in header of web template
# web_include_css = "/assets/custom_subtabs/css/custom_subtabs.css"

View File

@ -2,6 +2,7 @@ import json
import os
import sys
import shutil
import frappe
from frappe.utils import get_bench_path
def add_field_to_docfield_json():
@ -41,6 +42,16 @@ 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'
@ -61,6 +72,19 @@ def add_field_to_docfield_json():
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

@ -0,0 +1,80 @@
.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

@ -1,39 +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 {
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;
}
.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: 1px solid #000 !important; /* Черная активная полоска снизу */
background-color: transparent !important; /* Без фона */
border-radius: 0 !important; /* Без скруглений */
}

View File

@ -0,0 +1,2 @@
.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

@ -0,0 +1,7 @@
{
"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

@ -0,0 +1,2 @@
.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

@ -0,0 +1,7 @@
{
"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

@ -0,0 +1,2 @@
(()=>{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

@ -0,0 +1,235 @@
// 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

@ -1,177 +0,0 @@
frappe.ui.form.Layout.prototype.setup_tab_events = function () {
setTimeout(() => {
const tabs = this.wrapper.find("ul.form-tabs > li.nav-item");
// Получение текущего Doctype в нижнем регистре
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");
});
}
// Function to handle showing/hiding subtabs and sub-subtabs
function findParentAndShowSubtabs(currentTabName) {
if (!currentTabName) return;
let parentTabName = null;
// Find the parent tab and its subtabs
for (const [mainTab, subtabsArray] of Object.entries(subtabs)) {
if (subtabsArray.includes(currentTabName)) {
parentTabName = mainTab;
break;
}
}
if (!parentTabName) {
return;
}
// Show the subtabs for the parent tab
const parentSubtabs = subtabs[parentTabName] || [];
parentSubtabs.forEach((subtabName) => {
const subtabElement = $(`.nav-item:contains(${subtabName})`);
const subtabContentId = `${currentDoctype.toLowerCase().replace(/\s+/g, '-')}-${subtabName.toLowerCase().replace(/\s+/g, '_')}_tab`;
const subtabContent = $(`#${subtabContentId}`);
if (subtabElement.length) {
subtabElement.css("display", "block");
// If the current tab is the subtab, show its content
if (subtabName === currentTabName) {
subtabContent.addClass("active show").css("display", "block");
}
}
});
// Show the parent tab's content
const parentTabContentId = `${currentDoctype.toLowerCase().replace(/\s+/g, '-')}-${parentTabName.toLowerCase().replace(/\s+/g, '_')}_tab`;
showTabContent(parentTabContentId);
// Recursively handle the parent tab
findParentAndShowSubtabs(parentTabName);
}
// 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 sectionContainer = tabContent.find(".row.form-section.card-section.visible-section");
if (!sectionContainer.length) {
return;
}
let subtabContainer = sectionContainer.find('.sub-tab-container');
if (!subtabContainer.length) {
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");
subtabClone.find("a").removeAttr("href");
subtabClone.attr("data-parent-id", tabContentId);
subtabContainer.append(subtabClone);
subtabElement.remove();
}
});
}
});
// Handle main tab activation
$(".nav-item").on("click", 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").each(function () {
const containerParentId = $(this).attr("data-parent-id");
if (containerParentId === tabId) {
$(this).find(".sub-tab").css("display", "block");
} else {
$(this).find(".sub-tab").css("display", "none");
}
});
});
// Handle subtab activation
$(".sub-tab").on("click", function (event) {
event.stopPropagation();
const clickedSubtab = $(this);
const subtabId = clickedSubtab.find("a").attr("aria-controls");
const subtabName = clickedSubtab.text().trim();
$(".sub-tab").removeClass("active");
clickedSubtab.addClass("active");
hideAllTabContents();
const subtabContent = $(`#${subtabId}`);
if (subtabContent.length) {
subtabContent.addClass("active show").css("display", "block");
}
findParentAndShowSubtabs(subtabName);
});
// Debug log
setInterval(() => {
const activeMainTab = $(".nav-item.active").text().trim();
const activeSubTabs = $(".sub-tab.active")
.map(function () {
return $(this).text().trim();
})
.get();
const visibleContent = $(".form-tab-content > .tab-pane:visible")
.map(function () {
return $(this).attr("id");
})
.get();
}, 2000);
}, 500);
};