fixed subtabs for v16
This commit is contained in:
parent
e5e7e3b93c
commit
76b178b93a
|
|
@ -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
|
||||||
|
|
@ -13,10 +13,12 @@
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-tab-container .sub-tab a {
|
/* 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;
|
text-decoration: none !important;
|
||||||
display: block !important;
|
display: block !important;
|
||||||
padding: 10px 15px !important;
|
padding: 6px 15px !important;
|
||||||
color: #555 !important;
|
color: #555 !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
font-size: 14px !important;
|
font-size: 14px !important;
|
||||||
|
|
@ -26,14 +28,44 @@
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-tab-container .sub-tab a:hover {
|
.sub-tab-container .sub-tab a:hover,
|
||||||
color: #000 !important; /* Цвет текста при наведении */
|
.sub-tab-container .sub-tab button:hover {
|
||||||
|
color: #000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-tab-container .sub-tab.active a {
|
.sub-tab-container .sub-tab.active a,
|
||||||
color: #000 !important; /* Черный текст для активного таба */
|
.sub-tab-container .sub-tab.active button {
|
||||||
font-weight: 600 !important; /* Жирный шрифт */
|
color: #000 !important;
|
||||||
border-bottom: 1px solid #000 !important; /* Черная активная полоска снизу */
|
font-weight: 600 !important;
|
||||||
background-color: transparent !important; /* Без фона */
|
border-bottom: 2px solid #000 !important;
|
||||||
border-radius: 0 !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;
|
||||||
}
|
}
|
||||||
|
|
@ -1,177 +1,219 @@
|
||||||
frappe.ui.form.Layout.prototype.setup_tab_events = function () {
|
// Shared function for setting up subtabs
|
||||||
|
function setup_custom_subtabs(wrapper, currentDoctype) {
|
||||||
|
if (!wrapper || !wrapper.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
const tabs = wrapper.find("ul.form-tabs > li.nav-item");
|
||||||
const tabs = this.wrapper.find("ul.form-tabs > li.nav-item");
|
const hierarchy = frappe.boot.tab_hierarchy?.[currentDoctype];
|
||||||
|
|
||||||
// Получение текущего Doctype в нижнем регистре
|
if (!hierarchy) {
|
||||||
const currentDoctype = cur_frm.doctype;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hierarchy = frappe.boot.tab_hierarchy[currentDoctype];
|
const subtabs = hierarchy.subtabs || {};
|
||||||
|
|
||||||
if (!hierarchy) {
|
if (Object.keys(subtabs).length === 0) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to find tab link element
|
||||||
|
function getTabLink(tabElement) {
|
||||||
|
const $tab = $(tabElement);
|
||||||
|
let link = $tab.find("button.nav-link");
|
||||||
|
if (!link.length) {
|
||||||
|
link = $tab.find("a.nav-link");
|
||||||
}
|
}
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
const subtabs = hierarchy.subtabs || {};
|
// Check if already processed
|
||||||
|
if (wrapper.attr('data-subtabs-processed') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Function to show tab content
|
// Build parent-child map
|
||||||
function showTabContent(tabId) {
|
const childToParentMap = {};
|
||||||
const tabPane = document.getElementById(tabId);
|
for (const [parentTab, children] of Object.entries(subtabs)) {
|
||||||
if (tabPane) {
|
children.forEach(child => {
|
||||||
tabPane.style.display = 'block';
|
childToParentMap[child] = parentTab;
|
||||||
tabPane.classList.add('active', 'show');
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Function to hide all tab contents
|
// Process subtabs - move them into containers
|
||||||
function hideAllTabContents() {
|
tabs.each((index, tab) => {
|
||||||
$(".form-tab-content > .tab-pane").each(function () {
|
const tabLabel = $(tab).text().trim();
|
||||||
$(this).removeClass("active show").css("display", "none");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to handle showing/hiding subtabs and sub-subtabs
|
if (subtabs[tabLabel]) {
|
||||||
function findParentAndShowSubtabs(currentTabName) {
|
const tabLink = getTabLink(tab);
|
||||||
if (!currentTabName) return;
|
const tabContentId = tabLink.attr("aria-controls");
|
||||||
|
const tabContent = $(`#${tabContentId}`);
|
||||||
|
|
||||||
let parentTabName = null;
|
if (!tabContent.length) {
|
||||||
|
|
||||||
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the subtabs for the parent tab
|
// Find container
|
||||||
const parentSubtabs = subtabs[parentTabName] || [];
|
let sectionContainer = tabContent.find(".form-section.card-section");
|
||||||
parentSubtabs.forEach((subtabName) => {
|
if (!sectionContainer.length) {
|
||||||
const subtabElement = $(`.nav-item:contains(${subtabName})`);
|
sectionContainer = tabContent.find(".form-section");
|
||||||
const subtabContentId = `${currentDoctype.toLowerCase().replace(/\s+/g, '-')}-${subtabName.toLowerCase().replace(/\s+/g, '_')}_tab`;
|
}
|
||||||
const subtabContent = $(`#${subtabContentId}`);
|
if (!sectionContainer.length) {
|
||||||
|
sectionContainer = tabContent.children().first();
|
||||||
|
}
|
||||||
|
if (!sectionContainer.length) {
|
||||||
|
sectionContainer = tabContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtabContainer = sectionContainer.find('.sub-tab-container');
|
||||||
|
if (!subtabContainer.length) {
|
||||||
|
subtabContainer = $("<div class='sub-tab-container nav' data-parent-id='" + tabContentId + "'></div>");
|
||||||
|
sectionContainer.prepend(subtabContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
subtabs[tabLabel].forEach((subtab) => {
|
||||||
|
const subtabElement = tabs.filter((_, el) => $(el).text().trim() === subtab);
|
||||||
if (subtabElement.length) {
|
if (subtabElement.length) {
|
||||||
subtabElement.css("display", "block");
|
const subtabClone = subtabElement.clone(true);
|
||||||
|
subtabClone.addClass("sub-tab");
|
||||||
|
subtabClone.attr("data-subtab-name", subtab);
|
||||||
|
const clonedLink = getTabLink(subtabClone);
|
||||||
|
clonedLink.removeAttr("href");
|
||||||
|
clonedLink.attr("data-target-id", clonedLink.attr("aria-controls"));
|
||||||
|
subtabClone.attr("data-parent-id", tabContentId);
|
||||||
|
|
||||||
// If the current tab is the subtab, show its content
|
subtabContainer.append(subtabClone);
|
||||||
if (subtabName === currentTabName) {
|
subtabElement.remove();
|
||||||
subtabContent.addClass("active show").css("display", "block");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Show the parent tab's content
|
// Function to show parent chain
|
||||||
const parentTabContentId = `${currentDoctype.toLowerCase().replace(/\s+/g, '-')}-${parentTabName.toLowerCase().replace(/\s+/g, '_')}_tab`;
|
function ensureParentChainVisible(subtabName) {
|
||||||
showTabContent(parentTabContentId);
|
let currentChild = subtabName;
|
||||||
|
const parentsToShow = [];
|
||||||
|
|
||||||
// Recursively handle the parent tab
|
// Collect parent chain
|
||||||
findParentAndShowSubtabs(parentTabName);
|
while (childToParentMap[currentChild]) {
|
||||||
|
const parentName = childToParentMap[currentChild];
|
||||||
|
parentsToShow.push(parentName);
|
||||||
|
|
||||||
|
// Show all siblings
|
||||||
|
const siblings = subtabs[parentName] || [];
|
||||||
|
siblings.forEach(sibling => {
|
||||||
|
const siblingElement = wrapper.find(`.sub-tab[data-subtab-name="${sibling}"]`);
|
||||||
|
siblingElement.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
currentChild = parentName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process subtabs
|
// Show all parent content
|
||||||
tabs.each((index, tab) => {
|
parentsToShow.forEach(parentName => {
|
||||||
const tabLabel = $(tab).text().trim();
|
const parentTabContentId = `${currentDoctype.toLowerCase().replace(/\s+/g, '-')}-${parentName.toLowerCase().replace(/\s+/g, '_')}_tab`;
|
||||||
|
const parentContent = $(`#${parentTabContentId}`);
|
||||||
if (subtabs[tabLabel]) {
|
if (parentContent.length && !parentContent.hasClass('active')) {
|
||||||
const tabContentId = $(tab).find("a").attr("aria-controls");
|
parentContent.addClass('active show').css('display', 'block');
|
||||||
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
|
// Handle subtab clicks
|
||||||
$(".nav-item").on("click", function () {
|
wrapper.on("click.customsubtabs", ".sub-tab button.nav-link, .sub-tab a.nav-link", function(event) {
|
||||||
const clickedTab = $(this);
|
event.preventDefault();
|
||||||
const tabId = clickedTab.find("a").attr("aria-controls");
|
event.stopPropagation();
|
||||||
|
|
||||||
hideAllTabContents();
|
const clickedButton = $(this);
|
||||||
|
const clickedSubtab = clickedButton.closest(".sub-tab");
|
||||||
|
const subtabName = clickedSubtab.attr("data-subtab-name");
|
||||||
|
const targetId = clickedButton.attr("data-target-id") || clickedButton.attr("aria-controls");
|
||||||
|
|
||||||
const tabContent = document.querySelector(`#${tabId}`);
|
// Temporarily disable shown.bs.tab handler
|
||||||
if (tabContent) {
|
wrapper.off('shown.bs.tab.customsubtabs');
|
||||||
$(tabContent).addClass("active show").css("display", "block");
|
|
||||||
|
// Hide all tabs and content
|
||||||
|
wrapper.find(".nav-item").removeClass("active");
|
||||||
|
wrapper.find("button.nav-link, a.nav-link").removeClass("active").attr("aria-selected", "false");
|
||||||
|
wrapper.find(".tab-pane").removeClass("active show");
|
||||||
|
|
||||||
|
// Activate subtab
|
||||||
|
clickedSubtab.addClass("active");
|
||||||
|
clickedButton.addClass("active").attr("aria-selected", "true");
|
||||||
|
|
||||||
|
// Show subtab content
|
||||||
|
const targetPane = $(`#${targetId}`);
|
||||||
|
if (targetPane.length) {
|
||||||
|
targetPane.addClass("active show");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show parent chain
|
||||||
|
ensureParentChainVisible(subtabName);
|
||||||
|
|
||||||
|
// Restore shown.bs.tab handler
|
||||||
|
setTimeout(() => {
|
||||||
|
setupMainTabHandler();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to setup main tab handler
|
||||||
|
function setupMainTabHandler() {
|
||||||
|
wrapper.off("shown.bs.tab.customsubtabs")
|
||||||
|
.on("shown.bs.tab.customsubtabs", "button.nav-link:not(.sub-tab button.nav-link), a.nav-link:not(.sub-tab a.nav-link)", function(event) {
|
||||||
|
|
||||||
|
const button = event.target;
|
||||||
|
const tabId = button.getAttribute("aria-controls");
|
||||||
|
const li = $(button).closest("li.nav-item");
|
||||||
|
|
||||||
|
// If this is a subtab - ignore
|
||||||
|
if (li.hasClass("sub-tab")) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$(".nav-item").removeClass("active");
|
// ИСПРАВЛЕНИЕ: Убираем active классы со всех субтабов и их контента
|
||||||
clickedTab.addClass("active");
|
wrapper.find(".sub-tab").removeClass("active");
|
||||||
|
wrapper.find(".sub-tab button.nav-link, .sub-tab a.nav-link")
|
||||||
|
.removeClass("active")
|
||||||
|
.attr("aria-selected", "false");
|
||||||
|
|
||||||
$(".sub-tab-container").each(function () {
|
// Скрываем контент всех субтабов
|
||||||
|
wrapper.find(".sub-tab").each(function() {
|
||||||
|
const subtabButton = getTabLink($(this));
|
||||||
|
const subtabContentId = subtabButton.attr("data-target-id") || subtabButton.attr("aria-controls");
|
||||||
|
if (subtabContentId) {
|
||||||
|
$(`#${subtabContentId}`).removeClass("active show");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide subtabs
|
||||||
|
wrapper.find(".sub-tab-container").each(function() {
|
||||||
const containerParentId = $(this).attr("data-parent-id");
|
const containerParentId = $(this).attr("data-parent-id");
|
||||||
if (containerParentId === tabId) {
|
if (containerParentId === tabId) {
|
||||||
$(this).find(".sub-tab").css("display", "block");
|
$(this).find(".sub-tab").show();
|
||||||
} else {
|
} else {
|
||||||
$(this).find(".sub-tab").css("display", "none");
|
$(this).find(".sub-tab").hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle subtab activation
|
// Initialize handler
|
||||||
$(".sub-tab").on("click", function (event) {
|
setupMainTabHandler();
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const clickedSubtab = $(this);
|
// Mark as processed
|
||||||
const subtabId = clickedSubtab.find("a").attr("aria-controls");
|
wrapper.attr('data-subtabs-processed', 'true');
|
||||||
const subtabName = clickedSubtab.text().trim();
|
}
|
||||||
|
|
||||||
$(".sub-tab").removeClass("active");
|
// Use form refresh event
|
||||||
clickedSubtab.addClass("active");
|
frappe.ui.form.on('*', {
|
||||||
|
refresh: function(frm) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const wrapper = frm.layout?.wrapper || frm.$wrapper;
|
||||||
|
setup_custom_subtabs(wrapper, frm.doctype);
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
|
||||||
hideAllTabContents();
|
onload: function(frm) {
|
||||||
|
// Form onload hook
|
||||||
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);
|
|
||||||
};
|
|
||||||
Loading…
Reference in New Issue