Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
2056a43cce |
783
ARCHITECTURE.md
783
ARCHITECTURE.md
|
|
@ -1,783 +0,0 @@
|
|||
# Custom Subtabs - Подробная документация по архитектуре
|
||||
|
||||
## Обзор приложения
|
||||
|
||||
**Custom Subtabs** — это приложение для Frappe Framework, которое добавляет возможность создания вложенных вкладок (подвкладок) в формах DocType. Приложение позволяет организовать вкладки иерархически, когда одна вкладка может содержать другие подвкладки.
|
||||
|
||||
### Основные возможности:
|
||||
- Создание иерархии вкладок (parent-child отношения)
|
||||
- Автоматическое перемещение подвкладок под родительскую вкладку
|
||||
- Визуальное отображение подвкладок с отдельным стилем
|
||||
- Поддержка Frappe v15 и v16
|
||||
- Динамическая загрузка конфигурации через boot session
|
||||
|
||||
---
|
||||
|
||||
## Архитектура системы
|
||||
|
||||
### Общая схема работы
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frappe Backend │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ boot_session_handler │ │
|
||||
│ │ - Сканирует все DocType │ │
|
||||
│ │ - Находит Tab Break поля с js_parent_subtab │ │
|
||||
│ │ - Создает tab_hierarchy структуру │ │
|
||||
│ │ - Передает данные на клиент через bootinfo │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ add_parent_field.py (patch) │ │
|
||||
│ │ - Модифицирует docfield.json │ │
|
||||
│ │ - Добавляет поле js_parent_subtab в DocField │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
frappe.boot.tab_hierarchy
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frappe Frontend (Browser) │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ custom_subtabs.js │ │
|
||||
│ │ - Переопределяет setup_tab_events() │ │
|
||||
│ │ - Обрабатывает form refresh события │ │
|
||||
│ │ - Читает tab_hierarchy из frappe.boot │ │
|
||||
│ │ - Перемещает подвкладки в контейнеры │ │
|
||||
│ │ - Обрабатывает клики по вкладкам │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ custom_subtabs.css │ │
|
||||
│ │ - Стилизует sub-tab-container │ │
|
||||
│ │ - Определяет внешний вид подвкладок │ │
|
||||
│ │ - Активное состояние подвкладок │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Компоненты приложения
|
||||
|
||||
### 1. Backend компоненты
|
||||
|
||||
#### 1.1 `hooks.py`
|
||||
**Расположение:** `custom_subtabs/hooks.py`
|
||||
|
||||
Основной файл конфигурации приложения:
|
||||
- Подключает CSS и JS файлы к desk.html
|
||||
- Регистрирует `boot_session_handler` для передачи данных на клиент
|
||||
- Регистрирует патчи для выполнения после миграции
|
||||
|
||||
```python
|
||||
app_include_css = "/assets/custom_subtabs/css/custom_subtabs.css"
|
||||
app_include_js = "/assets/custom_subtabs/js/custom_subtabs.js"
|
||||
boot_session = "custom_subtabs.custom_subtabs.boot_session_handler"
|
||||
after_migrate = ["custom_subtabs.patches.add_parent_field.add_field_to_docfield_json"]
|
||||
```
|
||||
|
||||
#### 1.2 `boot_session_handler`
|
||||
**Расположение:** `custom_subtabs/custom_subtabs/__init__.py`
|
||||
|
||||
**Назначение:** Подготавливает данные о структуре вкладок для всех DocType и передает их на клиент.
|
||||
|
||||
**Алгоритм работы:**
|
||||
1. Получает список всех DocType в системе
|
||||
2. Для каждого DocType:
|
||||
- Получает метаданные через `frappe.get_meta()`
|
||||
- Ищет поля с `fieldtype == "Tab Break"`
|
||||
- Для каждой вкладки проверяет наличие `js_parent_subtab`
|
||||
- Строит структуру `tabs` (основные вкладки) и `subtabs` (подвкладки)
|
||||
3. Формирует объект `tab_hierarchy_data` вида:
|
||||
```javascript
|
||||
{
|
||||
"Customer": {
|
||||
"tabs": ["Details", "Settings"],
|
||||
"subtabs": {
|
||||
"Settings": ["Email Settings", "Privacy Settings"]
|
||||
}
|
||||
},
|
||||
"Item": {
|
||||
"tabs": ["Basic", "Pricing"],
|
||||
"subtabs": {
|
||||
"Pricing": ["Discounts", "Tax"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
4. Передает через `bootinfo.tab_hierarchy` на клиент
|
||||
|
||||
**Пример структуры:**
|
||||
```python
|
||||
tab_hierarchy_data = {
|
||||
"Customer": {
|
||||
"tabs": ["Details", "Settings", "Contact"],
|
||||
"subtabs": {
|
||||
"Settings": ["Email Settings", "Privacy Settings"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 `add_parent_field.py` (Patch)
|
||||
**Расположение:** `custom_subtabs/patches/add_parent_field.py`
|
||||
|
||||
**Назначение:** Модифицирует основной DocField DocType, добавляя поле `js_parent_subtab`.
|
||||
|
||||
**Что делает:**
|
||||
1. Находит файл `frappe/core/doctype/docfield/docfield.json`
|
||||
2. Добавляет новое поле:
|
||||
```json
|
||||
{
|
||||
"fieldname": "js_parent_subtab",
|
||||
"fieldtype": "Data",
|
||||
"label": "JS Parent Subtab",
|
||||
"description": "Укажите имя родительского таба"
|
||||
}
|
||||
```
|
||||
3. Вставляет поле после поля `reqd` в определении DocField
|
||||
4. Сохраняет изменения в JSON файл
|
||||
|
||||
**Зачем это нужно:**
|
||||
После выполнения этого патча, в интерфейсе настройки любого DocType, при добавлении поля Tab Break, появится дополнительное поле "JS Parent Subtab", где можно указать имя родительской вкладки.
|
||||
|
||||
#### 1.4 `custom_field.py` (Override)
|
||||
**Расположение:** `custom_subtabs/overrides/custom_field.py`
|
||||
|
||||
**Статус:** Закомментирован / не используется
|
||||
|
||||
Исходно был создан для автоматической настройки вложенных вкладок через override CustomField, но в текущей реализации не используется.
|
||||
|
||||
#### 1.5 `utils.py`
|
||||
**Расположение:** `custom_subtabs/utils.py`
|
||||
|
||||
**Статус:** Содержит функцию `process_nested_tabs`, которая не используется в текущей реализации
|
||||
|
||||
#### 1.6 `delete_custom_field.py`
|
||||
**Расположение:** `custom_subtabs/delete_custom_field.py`
|
||||
|
||||
Утилита для удаления Custom Field `DocField-js_parent_subtab` (если он был создан через Custom Field вместо модификации JSON).
|
||||
|
||||
---
|
||||
|
||||
### 2. Frontend компоненты
|
||||
|
||||
#### 2.1 `custom_subtabs.js`
|
||||
**Расположение:** `custom_subtabs/public/js/custom_subtabs.js`
|
||||
|
||||
Основной JavaScript файл, реализующий логику подвкладок на клиенте.
|
||||
|
||||
**Структура кода:**
|
||||
|
||||
##### A. Переопределение `frappe.ui.form.Layout.prototype.setup_tab_events`
|
||||
|
||||
```javascript
|
||||
const original_setup_tab_events = frappe.ui.form.Layout.prototype.setup_tab_events;
|
||||
|
||||
frappe.ui.form.Layout.prototype.setup_tab_events = function () {
|
||||
// Вызов оригинальной функции
|
||||
original_setup_tab_events.call(this);
|
||||
|
||||
setTimeout(() => {
|
||||
// Логика обработки подвкладок
|
||||
}, 500);
|
||||
};
|
||||
```
|
||||
|
||||
**Зачем переопределяется:** Frappe вызывает `setup_tab_events()` при рендеринге формы. Мы перехватываем этот вызов, чтобы добавить свою логику после стандартной обработки вкладок.
|
||||
|
||||
##### B. Альтернативный подход через `frappe.ui.form.on('*')`
|
||||
|
||||
```javascript
|
||||
frappe.ui.form.on('*', {
|
||||
refresh: function(frm) {
|
||||
setTimeout(() => {
|
||||
setup_custom_subtabs(frm);
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Зачем два подхода:**
|
||||
- Первый перехватывает встроенную логику Frappe
|
||||
- Второй использует event-based подход для совместимости с v16
|
||||
- Оба могут работать одновременно для максимальной совместимости
|
||||
|
||||
##### C. Функция `setup_custom_subtabs(frm)`
|
||||
|
||||
**Основная логика:**
|
||||
|
||||
1. **Получение данных:**
|
||||
```javascript
|
||||
const currentDoctype = frm.doctype;
|
||||
const hierarchy = frappe.boot.tab_hierarchy?.[currentDoctype];
|
||||
const subtabs = hierarchy.subtabs || {};
|
||||
```
|
||||
|
||||
2. **Поиск всех вкладок:**
|
||||
```javascript
|
||||
const tabs = wrapper.find("ul.form-tabs > li.nav-item");
|
||||
```
|
||||
|
||||
3. **Обработка каждой родительской вкладки:**
|
||||
```javascript
|
||||
tabs.each((index, tab) => {
|
||||
const tabLabel = $(tab).text().trim();
|
||||
|
||||
if (subtabs[tabLabel]) {
|
||||
// Это родительская вкладка с подвкладками
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
4. **Создание контейнера для подвкладок:**
|
||||
```javascript
|
||||
subtabContainer = $("<div class='sub-tab-container nav' data-parent-id='" + tabContentId + "'></div>");
|
||||
sectionContainer.append(subtabContainer);
|
||||
```
|
||||
|
||||
5. **Перемещение подвкладок:**
|
||||
```javascript
|
||||
subtabs[tabLabel].forEach((subtab) => {
|
||||
const subtabElement = tabs.filter((_, el) => $(el).text().trim() === subtab);
|
||||
|
||||
if (subtabElement.length) {
|
||||
const subtabClone = subtabElement.clone();
|
||||
subtabClone.addClass("sub-tab");
|
||||
subtabContainer.append(subtabClone);
|
||||
subtabElement.remove(); // Удаляем из основного nav
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
##### D. Helper функции
|
||||
|
||||
**`getTabLink(tabElement)`**
|
||||
```javascript
|
||||
function getTabLink(tabElement) {
|
||||
const $tab = $(tabElement);
|
||||
let link = $tab.find("button.nav-link");
|
||||
if (!link.length) {
|
||||
link = $tab.find("a.nav-link");
|
||||
}
|
||||
return link;
|
||||
}
|
||||
```
|
||||
**Назначение:** Поддержка v15 (используются `<a>`) и v16 (используются `<button>`).
|
||||
|
||||
**`hideAllTabContents()`**
|
||||
```javascript
|
||||
function hideAllTabContents() {
|
||||
$(".form-tab-content > .tab-pane").each(function () {
|
||||
$(this).removeClass("active show").css("display", "none");
|
||||
});
|
||||
}
|
||||
```
|
||||
**Назначение:** Скрывает все содержимое вкладок перед показом выбранной.
|
||||
|
||||
**`findParentAndShowSubtabs(currentTabName)`**
|
||||
```javascript
|
||||
function findParentAndShowSubtabs(currentTabName) {
|
||||
// 1. Найти родительскую вкладку для currentTabName
|
||||
// 2. Показать все подвкладки этой родительской вкладки
|
||||
// 3. Показать содержимое родительской вкладки
|
||||
// 4. Рекурсивно обработать родительскую вкладку (если она сама подвкладка)
|
||||
}
|
||||
```
|
||||
**Назначение:** Рекурсивная функция для обработки многоуровневых вложенностей (sub-subtabs).
|
||||
|
||||
##### E. Обработчики событий
|
||||
|
||||
**Клик по основной вкладке:**
|
||||
```javascript
|
||||
wrapper.off("click", ".nav-item").on("click", ".nav-item", function () {
|
||||
const clickedTab = $(this);
|
||||
const tabId = getTabLink(clickedTab).attr("aria-controls");
|
||||
|
||||
// 1. Скрыть всё содержимое
|
||||
hideAllTabContents();
|
||||
|
||||
// 2. Показать содержимое выбранной вкладки
|
||||
$(tabContent).addClass("active show").css("display", "block");
|
||||
|
||||
// 3. Показать/скрыть подвкладки в зависимости от родительской вкладки
|
||||
$(".sub-tab-container").each(function () {
|
||||
if ($(this).attr("data-parent-id") === tabId) {
|
||||
$(this).find(".sub-tab").css("display", "block");
|
||||
} else {
|
||||
$(this).find(".sub-tab").css("display", "none");
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Клик по подвкладке:**
|
||||
```javascript
|
||||
wrapper.off("click", ".sub-tab").on("click", ".sub-tab", function (event) {
|
||||
event.stopPropagation(); // Не вызывать обработчик родительской вкладки
|
||||
|
||||
const clickedSubtab = $(this);
|
||||
const subtabId = getTabLink(clickedSubtab).attr("aria-controls");
|
||||
const subtabName = clickedSubtab.text().trim();
|
||||
|
||||
// 1. Убрать active с других подвкладок
|
||||
$(".sub-tab").removeClass("active");
|
||||
clickedSubtab.addClass("active");
|
||||
|
||||
// 2. Скрыть всё содержимое
|
||||
hideAllTabContents();
|
||||
|
||||
// 3. Показать содержимое выбранной подвкладки
|
||||
$(subtabContent).addClass("active show").css("display", "block");
|
||||
|
||||
// 4. Показать родительскую цепочку
|
||||
findParentAndShowSubtabs(subtabName);
|
||||
});
|
||||
```
|
||||
|
||||
#### 2.2 `custom_subtabs.css`
|
||||
**Расположение:** `custom_subtabs/public/css/custom_subtabs.css`
|
||||
|
||||
Стили для визуального оформления подвкладок.
|
||||
|
||||
**Структура стилей:**
|
||||
|
||||
```css
|
||||
.sub-tab-container {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
justify-content: flex-start !important;
|
||||
border-bottom: 1px solid #ddd !important;
|
||||
}
|
||||
```
|
||||
**Назначение:** Контейнер для подвкладок, отображается как flexbox для горизонтального расположения.
|
||||
|
||||
```css
|
||||
.sub-tab-container .sub-tab {
|
||||
margin: 0 !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
```
|
||||
**Назначение:** Сброс стилей для элементов подвкладок.
|
||||
|
||||
```css
|
||||
.sub-tab-container .sub-tab a,
|
||||
.sub-tab-container .sub-tab button {
|
||||
text-decoration: none !important;
|
||||
display: block !important;
|
||||
padding: 10px 15px !important;
|
||||
color: #555 !important;
|
||||
border: none !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
```
|
||||
**Назначение:** Стилизация ссылок и кнопок внутри подвкладок (поддержка v15/v16).
|
||||
|
||||
```css
|
||||
.sub-tab-container .sub-tab.active a,
|
||||
.sub-tab-container .sub-tab.active button {
|
||||
color: #000 !important;
|
||||
font-weight: 600 !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
```
|
||||
**Назначение:** Стилизация активной подвкладки (черный текст, жирный шрифт, черная линия снизу).
|
||||
|
||||
---
|
||||
|
||||
## Как работают подвкладки (Subtabs)
|
||||
|
||||
### Пошаговый процесс
|
||||
|
||||
#### Шаг 1: Настройка в DocType
|
||||
|
||||
1. Администратор открывает DocType через "Customize Form"
|
||||
2. Добавляет поле Tab Break (например, "Settings")
|
||||
3. Добавляет еще одно поле Tab Break (например, "Email Settings")
|
||||
4. В поле "Email Settings" заполняет `JS Parent Subtab` значением "Settings"
|
||||
|
||||
**Результат:** Вкладка "Email Settings" станет подвкладкой для "Settings".
|
||||
|
||||
#### Шаг 2: Backend обработка (boot session)
|
||||
|
||||
При загрузке страницы выполняется `boot_session_handler`:
|
||||
|
||||
```python
|
||||
# Для Customer DocType
|
||||
meta = frappe.get_meta("Customer")
|
||||
for field in meta.fields:
|
||||
if field.fieldtype == "Tab Break":
|
||||
tab_name = field.label # "Email Settings"
|
||||
js_parent = field.js_parent_subtab # "Settings"
|
||||
|
||||
if js_parent:
|
||||
subtabs["Settings"].append("Email Settings")
|
||||
```
|
||||
|
||||
**Результат:** Создается структура `tab_hierarchy`:
|
||||
```python
|
||||
{
|
||||
"Customer": {
|
||||
"tabs": ["Details", "Settings", "Contact"],
|
||||
"subtabs": {
|
||||
"Settings": ["Email Settings", "Privacy Settings"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 3: Передача данных на клиент
|
||||
|
||||
```python
|
||||
bootinfo.tab_hierarchy = tab_hierarchy_data
|
||||
```
|
||||
|
||||
**Результат:** На клиенте доступен `frappe.boot.tab_hierarchy`.
|
||||
|
||||
#### Шаг 4: Frontend обработка (отрисовка формы)
|
||||
|
||||
Когда пользователь открывает форму Customer:
|
||||
|
||||
1. **Frappe рендерит все вкладки как обычно:**
|
||||
```html
|
||||
<ul class="nav form-tabs">
|
||||
<li class="nav-item">Details</li>
|
||||
<li class="nav-item">Settings</li>
|
||||
<li class="nav-item">Email Settings</li> <!-- Будет перемещена -->
|
||||
<li class="nav-item">Privacy Settings</li> <!-- Будет перемещена -->
|
||||
<li class="nav-item">Contact</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
2. **Вызывается `setup_custom_subtabs()`:**
|
||||
- Читает `frappe.boot.tab_hierarchy["Customer"]`
|
||||
- Находит `subtabs["Settings"] = ["Email Settings", "Privacy Settings"]`
|
||||
- Находит в DOM элементы с текстом "Email Settings" и "Privacy Settings"
|
||||
- Создает контейнер `.sub-tab-container` внутри контента вкладки "Settings"
|
||||
- Клонирует элементы подвкладок в контейнер
|
||||
- Удаляет оригинальные элементы из основного nav
|
||||
|
||||
3. **Результат HTML:**
|
||||
```html
|
||||
<ul class="nav form-tabs">
|
||||
<li class="nav-item">Details</li>
|
||||
<li class="nav-item">Settings</li>
|
||||
<li class="nav-item">Contact</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-pane" id="settings-tab-content">
|
||||
<div class="sub-tab-container">
|
||||
<li class="nav-item sub-tab">Email Settings</li>
|
||||
<li class="nav-item sub-tab">Privacy Settings</li>
|
||||
</div>
|
||||
<!-- Содержимое вкладки Settings -->
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Шаг 5: Взаимодействие пользователя
|
||||
|
||||
**Сценарий 1: Клик по основной вкладке "Settings"**
|
||||
1. Скрываются все `.tab-pane`
|
||||
2. Показывается `#settings-tab-content`
|
||||
3. Показываются все `.sub-tab` внутри `.sub-tab-container[data-parent-id="settings-tab-content"]`
|
||||
|
||||
**Сценарий 2: Клик по подвкладке "Email Settings"**
|
||||
1. Скрываются все `.tab-pane`
|
||||
2. Показывается `#email-settings-tab-content`
|
||||
3. Добавляется класс `active` к подвкладке "Email Settings"
|
||||
4. Вызывается `findParentAndShowSubtabs("Email Settings")` для показа родительской цепочки
|
||||
|
||||
---
|
||||
|
||||
## Поддержка многоуровневых вложенностей
|
||||
|
||||
Приложение поддерживает sub-subtabs через рекурсивную функцию `findParentAndShowSubtabs`.
|
||||
|
||||
**Пример структуры:**
|
||||
```
|
||||
Settings (основная вкладка)
|
||||
├── Email Settings (подвкладка)
|
||||
│ ├── SMTP Settings (под-подвкладка)
|
||||
│ └── IMAP Settings (под-подвкладка)
|
||||
└── Privacy Settings (подвкладка)
|
||||
```
|
||||
|
||||
**Конфигурация:**
|
||||
```javascript
|
||||
{
|
||||
"subtabs": {
|
||||
"Settings": ["Email Settings", "Privacy Settings"],
|
||||
"Email Settings": ["SMTP Settings", "IMAP Settings"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Как работает:**
|
||||
1. Пользователь кликает на "SMTP Settings"
|
||||
2. `findParentAndShowSubtabs("SMTP Settings")` находит родителя "Email Settings"
|
||||
3. Показывает все подвкладки "Email Settings" (включая "SMTP Settings")
|
||||
4. Рекурсивно вызывается для "Email Settings"
|
||||
5. Находит родителя "Settings"
|
||||
6. Показывает все подвкладки "Settings" (включая "Email Settings")
|
||||
|
||||
**Результат:** Пользователь видит всю цепочку: Settings > Email Settings > SMTP Settings
|
||||
|
||||
---
|
||||
|
||||
## Совместимость с версиями Frappe
|
||||
|
||||
### Frappe v15
|
||||
- Использует `<a class="nav-link">` для вкладок
|
||||
- Функция `getTabLink()` находит `<a>` элемент
|
||||
|
||||
### Frappe v16
|
||||
- Использует `<button class="nav-link">` для вкладок
|
||||
- Функция `getTabLink()` находит `<button>` элемент
|
||||
|
||||
**Универсальная реализация:**
|
||||
```javascript
|
||||
function getTabLink(tabElement) {
|
||||
const $tab = $(tabElement);
|
||||
let link = $tab.find("button.nav-link");
|
||||
if (!link.length) {
|
||||
link = $tab.find("a.nav-link");
|
||||
}
|
||||
return link;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ключевые файлы и их назначение
|
||||
|
||||
| Файл | Назначение | Когда выполняется |
|
||||
|------|-----------|-------------------|
|
||||
| `hooks.py` | Конфигурация приложения | При загрузке Frappe |
|
||||
| `boot_session_handler` | Создание tab_hierarchy | При каждом входе пользователя |
|
||||
| `add_parent_field.py` | Добавление поля js_parent_subtab | После миграции (once) |
|
||||
| `custom_subtabs.js` | Логика подвкладок на клиенте | При загрузке каждой страницы |
|
||||
| `custom_subtabs.css` | Стили подвкладок | При загрузке каждой страницы |
|
||||
| `delete_custom_field.py` | Очистка Custom Field | Вручную (если нужно) |
|
||||
|
||||
---
|
||||
|
||||
## Настройка и использование
|
||||
|
||||
### Установка
|
||||
|
||||
1. **Установить приложение:**
|
||||
```bash
|
||||
bench get-app https://github.com/your-repo/custom_subtabs
|
||||
bench --site site_name install-app custom_subtabs
|
||||
```
|
||||
|
||||
2. **Запустить миграцию:**
|
||||
```bash
|
||||
bench --site site_name migrate
|
||||
```
|
||||
Это выполнит `add_parent_field.py` и добавит поле `js_parent_subtab` в DocField.
|
||||
|
||||
3. **Перезапустить bench:**
|
||||
```bash
|
||||
bench restart
|
||||
```
|
||||
|
||||
### Использование
|
||||
|
||||
1. **Открыть DocType для настройки:**
|
||||
- Перейти в "Customize Form"
|
||||
- Выбрать нужный DocType (например, Customer)
|
||||
|
||||
2. **Настроить вкладки:**
|
||||
- Добавить Tab Break с именем "Settings"
|
||||
- Добавить Tab Break с именем "Email Settings"
|
||||
- В поле "Email Settings" указать "JS Parent Subtab" = "Settings"
|
||||
|
||||
3. **Сохранить и обновить:**
|
||||
- Сохранить изменения
|
||||
- Обновить страницу
|
||||
- Открыть любую форму этого DocType
|
||||
|
||||
4. **Результат:**
|
||||
- Вкладка "Email Settings" появится под "Settings" как подвкладка
|
||||
- При клике на "Settings" покажутся все подвкладки
|
||||
|
||||
---
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Идентификация элементов
|
||||
|
||||
**Tab content ID формируется Frappe:**
|
||||
```javascript
|
||||
const tabContentId = tabLink.attr("aria-controls");
|
||||
// Пример: "customer-settings_tab"
|
||||
```
|
||||
|
||||
**Связь между nav-item и tab-pane:**
|
||||
```html
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" aria-controls="customer-settings_tab">Settings</button>
|
||||
</li>
|
||||
|
||||
<div class="tab-pane" id="customer-settings_tab">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Атрибут data-parent-id
|
||||
|
||||
Контейнер подвкладок хранит ID родительской вкладки:
|
||||
```html
|
||||
<div class="sub-tab-container" data-parent-id="customer-settings_tab">
|
||||
<li class="nav-item sub-tab" data-parent-id="customer-settings_tab">Email Settings</li>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Использование:**
|
||||
Когда пользователь кликает на основную вкладку, скрипт ищет все `.sub-tab-container` с соответствующим `data-parent-id` и показывает их.
|
||||
|
||||
### Таймауты
|
||||
|
||||
```javascript
|
||||
setTimeout(() => {
|
||||
setup_custom_subtabs(frm);
|
||||
}, 500);
|
||||
```
|
||||
|
||||
**Зачем:** Frappe рендерит вкладки асинхронно. Таймаут дает время для завершения рендеринга перед обработкой подвкладок.
|
||||
|
||||
### Event delegation
|
||||
|
||||
```javascript
|
||||
wrapper.off("click", ".sub-tab").on("click", ".sub-tab", function () { ... });
|
||||
```
|
||||
|
||||
**Зачем `off()` перед `on()`:** Предотвращает дублирование обработчиков при повторном вызове функции (например, при refresh формы).
|
||||
|
||||
---
|
||||
|
||||
## Ограничения и известные проблемы
|
||||
|
||||
### 1. Имена вкладок должны быть уникальными
|
||||
Скрипт использует `text().trim()` для поиска вкладок. Если две вкладки имеют одинаковое имя, возможны конфликты.
|
||||
|
||||
### 2. Производительность при большом количестве DocType
|
||||
`boot_session_handler` сканирует все DocType при каждом входе пользователя. При большом количестве DocType (>1000) это может замедлить загрузку.
|
||||
|
||||
**Решение:** Добавить кэширование или фильтрацию только нужных DocType.
|
||||
|
||||
### 3. Язык интерфейса
|
||||
Имена вкладок должны совпадать точно, включая локализацию. Если вкладка переведена, нужно использовать переведенное имя в `js_parent_subtab`.
|
||||
|
||||
### 4. Динамическое добавление вкладок
|
||||
Если вкладки добавляются динамически через JS после загрузки формы, подвкладки могут не обработаться.
|
||||
|
||||
**Решение:** Вызвать `setup_custom_subtabs(frm)` вручную после добавления вкладок.
|
||||
|
||||
---
|
||||
|
||||
## Расширение функциональности
|
||||
|
||||
### Добавление новых стилей
|
||||
|
||||
Отредактировать `custom_subtabs/public/css/custom_subtabs.css`:
|
||||
|
||||
```css
|
||||
.sub-tab-container .sub-tab.active button {
|
||||
color: #ff5733 !important; /* Красный цвет для активной подвкладки */
|
||||
}
|
||||
```
|
||||
|
||||
### Поддержка вертикальных вкладок
|
||||
|
||||
Изменить `.sub-tab-container`:
|
||||
```css
|
||||
.sub-tab-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
```
|
||||
|
||||
### Иконки для подвкладок
|
||||
|
||||
Добавить логику в JS:
|
||||
```javascript
|
||||
const iconMapping = {
|
||||
"Email Settings": "fa-envelope",
|
||||
"Privacy Settings": "fa-lock"
|
||||
};
|
||||
|
||||
subtabClone.prepend(`<i class="fa ${iconMapping[subtab]}"></i>`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
### Включение логов
|
||||
|
||||
Скрипт содержит множество `console.log()` для отладки:
|
||||
```javascript
|
||||
console.log("Hierarchy for doctype:", hierarchy);
|
||||
console.log("Processing parent tab:", tabLabel);
|
||||
console.log("Looking for subtab:", subtab, "found:", subtabElement.length);
|
||||
```
|
||||
|
||||
**Открыть консоль браузера (F12)** и проверить логи при загрузке формы.
|
||||
|
||||
### Проверка tab_hierarchy
|
||||
|
||||
В консоли браузера:
|
||||
```javascript
|
||||
console.log(frappe.boot.tab_hierarchy);
|
||||
```
|
||||
|
||||
**Ожидаемый результат:**
|
||||
```javascript
|
||||
{
|
||||
"Customer": {
|
||||
"tabs": ["Details", "Settings"],
|
||||
"subtabs": {
|
||||
"Settings": ["Email Settings", "Privacy Settings"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Проверка DOM структуры
|
||||
|
||||
```javascript
|
||||
// Проверить все контейнеры подвкладок
|
||||
$(".sub-tab-container").each(function() {
|
||||
console.log("Parent ID:", $(this).attr("data-parent-id"));
|
||||
console.log("Subtabs count:", $(this).find(".sub-tab").length);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Заключение
|
||||
|
||||
Custom Subtabs — это полноценное приложение для организации иерархических вкладок в Frappe. Оно использует:
|
||||
- **Backend:** Python для генерации конфигурации и модификации DocField
|
||||
- **Frontend:** JavaScript для динамической перестройки DOM и обработки событий
|
||||
- **CSS:** Для стилизации подвкладок
|
||||
|
||||
Приложение полностью интегрируется с Frappe Framework и поддерживает версии v15 и v16.
|
||||
|
||||
---
|
||||
|
||||
## Контакты
|
||||
|
||||
- **Автор:** Jey Soft
|
||||
- **Email:** info@jeyerp.az
|
||||
- **Лицензия:** MIT
|
||||
|
|
@ -6,7 +6,6 @@ def boot_session_handler(bootinfo):
|
|||
tab_hierarchy_data = {}
|
||||
|
||||
for doctype in all_doctypes:
|
||||
try:
|
||||
meta = frappe.get_meta(doctype["name"])
|
||||
|
||||
# Проверяем, есть ли вкладки (Tab Break) в Doctype
|
||||
|
|
@ -19,7 +18,7 @@ def boot_session_handler(bootinfo):
|
|||
for field in meta.fields:
|
||||
if field.fieldtype == "Tab Break":
|
||||
tab_name = field.label
|
||||
js_parent = getattr(field, 'js_parent_subtab', None) or None
|
||||
js_parent = field.js_parent_subtab or None
|
||||
|
||||
if js_parent:
|
||||
if js_parent not in subtabs:
|
||||
|
|
@ -33,10 +32,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -25,9 +25,8 @@ app_license = "mit"
|
|||
# ------------------
|
||||
|
||||
# include js, css files in header of desk.html
|
||||
# Hashed bundles → automatic cache-busting (no nginx 1-year stale-asset issue).
|
||||
app_include_css = "custom_subtabs.bundle.css"
|
||||
app_include_js = "custom_subtabs.bundle.js"
|
||||
app_include_css = "/assets/custom_subtabs/css/custom_subtabs.css"
|
||||
app_include_js = "/assets/custom_subtabs/js/custom_subtabs.js"
|
||||
|
||||
# include js, css files in header of web template
|
||||
# web_include_css = "/assets/custom_subtabs/css/custom_subtabs.css"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import json
|
|||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import frappe
|
||||
from frappe.utils import get_bench_path
|
||||
|
||||
def add_field_to_docfield_json():
|
||||
|
|
@ -42,16 +41,6 @@ def add_field_to_docfield_json():
|
|||
for field in docfield_def.get('fields', []):
|
||||
if field.get('fieldname') == 'js_parent_subtab':
|
||||
print("⚠️ Поле 'js_parent_subtab' уже существует в определении DocField.")
|
||||
|
||||
# Перезагружаем DocType на случай, если поле есть в JSON, но не в БД
|
||||
try:
|
||||
if frappe.db:
|
||||
frappe.reload_doc('core', 'doctype', 'docfield')
|
||||
frappe.clear_cache()
|
||||
print("✅ DocType 'DocField' перезагружен в базе данных и кэш очищен")
|
||||
except Exception as reload_error:
|
||||
print(f"⚠️ Не удалось перезагрузить DocType: {str(reload_error)}")
|
||||
|
||||
return True
|
||||
|
||||
# Добавляем новое поле в список полей после поля 'reqd'
|
||||
|
|
@ -72,19 +61,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
.sub-tab-container {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
justify-content: flex-start !important;
|
||||
border-bottom: 1px solid #ddd !important;
|
||||
}
|
||||
|
||||
.sub-tab-container .sub-tab {
|
||||
margin: 0 !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Support both <a> and <button> for v15 and v16 compatibility */
|
||||
.sub-tab-container .sub-tab a,
|
||||
.sub-tab-container .sub-tab button {
|
||||
text-decoration: none !important;
|
||||
display: block !important;
|
||||
padding: 6px 15px !important;
|
||||
color: #555 !important;
|
||||
border: none !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: normal !important;
|
||||
margin: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sub-tab-container .sub-tab a:hover,
|
||||
.sub-tab-container .sub-tab button:hover {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.sub-tab-container .sub-tab.active a,
|
||||
.sub-tab-container .sub-tab.active button {
|
||||
color: #000 !important;
|
||||
font-weight: 600 !important;
|
||||
border-bottom: 2px solid #000 !important;
|
||||
background-color: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* КЛЮЧЕВОЕ ИСПРАВЛЕНИЕ: убрать padding у form-section с субтабами */
|
||||
.form-section:has(.sub-tab-container) {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Скрыть пустые section-body */
|
||||
.form-section .section-body:empty,
|
||||
.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname="html_vohk"] > span.tooltip-content:only-child) {
|
||||
display: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
/* Исправить структуру sub-tab-container */
|
||||
.sub-tab-container.nav {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sub-tab-container.nav > li[style*="display: none"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Класс для скрытия контейнера субтабов */
|
||||
.sub-tab-container.subtab-hidden {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/* Основной контейнер субтабов */
|
||||
.sub-tab-container {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
justify-content: flex-start !important;
|
||||
border-bottom: 1px solid #dee2e6 !important;
|
||||
background-color: #fff !important;
|
||||
position: relative !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/* Отдельные субтабы */
|
||||
.sub-tab-container .sub-tab {
|
||||
margin: 0 !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
/* Ссылки в субтабах */
|
||||
.sub-tab-container .sub-tab a {
|
||||
text-decoration: none !important;
|
||||
display: block !important;
|
||||
padding: 10px 15px !important;
|
||||
color: #555 !important;
|
||||
border: none !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: normal !important;
|
||||
margin: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
transition: color 0.15s ease-in-out !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.sub-tab-container .sub-tab a:hover {
|
||||
color: #000 !important; /* Только изменение цвета, без фона */
|
||||
}
|
||||
|
||||
/* Активный субтаб */
|
||||
.sub-tab-container .sub-tab.active a {
|
||||
color: #000 !important;
|
||||
font-weight: 600 !important;
|
||||
border-bottom: 2px solid #000 !important;
|
||||
background-color: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
margin-bottom: -1px !important; /* Перекрываем нижнюю границу контейнера */
|
||||
}
|
||||
|
||||
/* Интеграция с основными табами ERPNext */
|
||||
.form-tabs + .sub-tab-container {
|
||||
border-top: none !important;
|
||||
margin-top: -1px !important;
|
||||
}
|
||||
|
||||
/* Убираем лишние отступы для форм с субтабами */
|
||||
.sub-tab-container + .form-layout,
|
||||
.sub-tab-container ~ .form-layout {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.sub-tab-container {
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
.sub-tab-container .sub-tab a {
|
||||
padding: 8px 12px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Убираем конфликтующие стили Bootstrap */
|
||||
.sub-tab-container .nav-link {
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Плавное появление/исчезание */
|
||||
.sub-tab-container {
|
||||
opacity: 1 !important;
|
||||
transition: opacity 0.2s ease-in-out !important;
|
||||
}
|
||||
|
||||
.sub-tab-container[style*="display: none"] {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* Убираем лишние границы и тени */
|
||||
.sub-tab-container .sub-tab,
|
||||
.sub-tab-container .sub-tab a,
|
||||
.sub-tab-container .nav-item {
|
||||
box-shadow: none !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
.sub-tab-container{margin:0!important;padding:0!important;display:flex!important;justify-content:flex-start!important;border-bottom:1px solid #ddd!important}.sub-tab-container .sub-tab{margin:0!important;background:none!important;border:none!important;padding:0!important}.sub-tab-container .sub-tab a,.sub-tab-container .sub-tab button{text-decoration:none!important;display:block!important;padding:6px 15px!important;color:#555!important;border:none!important;font-size:14px!important;font-weight:400!important;margin:0!important;background:transparent!important;box-shadow:none!important}.sub-tab-container .sub-tab a:hover,.sub-tab-container .sub-tab button:hover{color:#000!important}.sub-tab-container .sub-tab.active a,.sub-tab-container .sub-tab.active button{color:#000!important;font-weight:600!important;border-bottom:2px solid #000!important;background-color:transparent!important;border-radius:0!important}.form-section:has(.sub-tab-container){padding-top:0!important;padding-bottom:0!important;margin-bottom:0!important}.form-section .section-body:empty,.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname="html_vohk"] > span.tooltip-content:only-child){display:none!important;padding:0!important;margin:0!important;min-height:0!important}.sub-tab-container.nav{display:flex;list-style:none;padding:0;margin:0}.sub-tab-container.nav>li[style*="display: none"]{display:none!important}.sub-tab-container.subtab-hidden{display:none!important;visibility:hidden!important;pointer-events:none!important;height:0!important;overflow:hidden!important}
|
||||
/*# sourceMappingURL=custom_subtabs.bundle.DYDKGLKL.css.map */
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": ["../../../../../../../../tmp/tmp-15812-S7Giyd0UpxCO/custom_subtabs/custom_subtabs/public/css/custom_subtabs.bundle.css"],
|
||||
"sourcesContent": [".sub-tab-container {\n margin: 0 !important;\n padding: 0 !important;\n display: flex !important;\n justify-content: flex-start !important;\n border-bottom: 1px solid #ddd !important;\n}\n\n.sub-tab-container .sub-tab {\n margin: 0 !important;\n background: none !important;\n border: none !important;\n padding: 0 !important;\n}\n\n/* Support both <a> and <button> for v15 and v16 compatibility */\n.sub-tab-container .sub-tab a,\n.sub-tab-container .sub-tab button {\n text-decoration: none !important;\n display: block !important;\n padding: 6px 15px !important;\n color: #555 !important;\n border: none !important;\n font-size: 14px !important;\n font-weight: normal !important;\n margin: 0 !important;\n background: transparent !important;\n box-shadow: none !important;\n}\n\n.sub-tab-container .sub-tab a:hover,\n.sub-tab-container .sub-tab button:hover {\n color: #000 !important;\n}\n\n.sub-tab-container .sub-tab.active a,\n.sub-tab-container .sub-tab.active button {\n color: #000 !important;\n font-weight: 600 !important;\n border-bottom: 2px solid #000 !important;\n background-color: transparent !important;\n border-radius: 0 !important;\n}\n\n/* \u041A\u041B\u042E\u0427\u0415\u0412\u041E\u0415 \u0418\u0421\u041F\u0420\u0410\u0412\u041B\u0415\u041D\u0418\u0415: \u0443\u0431\u0440\u0430\u0442\u044C padding \u0443 form-section \u0441 \u0441\u0443\u0431\u0442\u0430\u0431\u0430\u043C\u0438 */\n.form-section:has(.sub-tab-container) {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n margin-bottom: 0 !important;\n}\n\n/* \u0421\u043A\u0440\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u0435 section-body */\n.form-section .section-body:empty,\n.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname=\"html_vohk\"] > span.tooltip-content:only-child) {\n display: none !important;\n padding: 0 !important;\n margin: 0 !important;\n min-height: 0 !important;\n}\n\n/* \u0418\u0441\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0443 sub-tab-container */\n.sub-tab-container.nav {\n display: flex;\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.sub-tab-container.nav > li[style*=\"display: none\"] {\n display: none !important;\n}\n\n/* \u041A\u043B\u0430\u0441\u0441 \u0434\u043B\u044F \u0441\u043A\u0440\u044B\u0442\u0438\u044F \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430 \u0441\u0443\u0431\u0442\u0430\u0431\u043E\u0432 */\n.sub-tab-container.subtab-hidden {\n display: none !important;\n visibility: hidden !important;\n pointer-events: none !important;\n height: 0 !important;\n overflow: hidden !important;\n}"],
|
||||
"mappings": "AAAA,0DAGI,uBACA,qCACA,uCAGJ,4BARA,mBAUI,0BACA,sBAXJ,oBAgBA,iEAEI,+BACA,wBAnBJ,2BAqBI,qBACA,sBACA,yBACA,0BAxBJ,mBA0BI,iCACA,0BAGJ,6EAEI,qBAGJ,+EAEI,qBACA,0BACA,uCACA,uCAxCJ,0BA6CA,sCACI,wBACA,2BACA,0BAIJ,yKAEI,uBAtDJ,uCAyDI,uBAIJ,uBACI,aACA,gBA/DJ,mBAoEA,kDACI,uBAIJ,iCACI,uBACA,4BACA,8BACA,mBACA",
|
||||
"names": []
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
.sub-tab-container{margin:0!important;padding:0!important;display:flex!important;justify-content:flex-start!important;border-bottom:1px solid #ddd!important}.sub-tab-container .sub-tab{margin:0!important;background:none!important;border:none!important;padding:0!important}.sub-tab-container .sub-tab a,.sub-tab-container .sub-tab button{text-decoration:none!important;display:block!important;padding:6px 15px!important;color:#555!important;border:none!important;font-size:14px!important;font-weight:400!important;margin:0!important;background:transparent!important;box-shadow:none!important}.sub-tab-container .sub-tab a:hover,.sub-tab-container .sub-tab button:hover{color:#000!important}.sub-tab-container .sub-tab.active a,.sub-tab-container .sub-tab.active button{color:#000!important;font-weight:600!important;border-bottom:2px solid #000!important;background-color:transparent!important;border-radius:0!important}.form-section:has(.sub-tab-container){padding-top:0!important;padding-bottom:0!important;margin-bottom:0!important}.form-section .section-body:empty,.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname="html_vohk"] > span.tooltip-content:only-child){display:none!important;padding:0!important;margin:0!important;min-height:0!important}.sub-tab-container.nav{display:flex;list-style:none;padding:0;margin:0}.sub-tab-container.nav>li[style*="display: none"]{display:none!important}.sub-tab-container.subtab-hidden{display:none!important;visibility:hidden!important;pointer-events:none!important;height:0!important;overflow:hidden!important}
|
||||
/*# sourceMappingURL=custom_subtabs.bundle.CJRWDWV2.css.map */
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": ["../../../../../../../../tmp/tmp-15812-HYmC3u994rit/custom_subtabs/custom_subtabs/public/css/custom_subtabs.bundle.css"],
|
||||
"sourcesContent": [".sub-tab-container {\n margin: 0 !important;\n padding: 0 !important;\n display: flex !important;\n justify-content: flex-start !important;\n border-bottom: 1px solid #ddd !important;\n}\n\n.sub-tab-container .sub-tab {\n margin: 0 !important;\n background: none !important;\n border: none !important;\n padding: 0 !important;\n}\n\n/* Support both <a> and <button> for v15 and v16 compatibility */\n.sub-tab-container .sub-tab a,\n.sub-tab-container .sub-tab button {\n text-decoration: none !important;\n display: block !important;\n padding: 6px 15px !important;\n color: #555 !important;\n border: none !important;\n font-size: 14px !important;\n font-weight: normal !important;\n margin: 0 !important;\n background: transparent !important;\n box-shadow: none !important;\n}\n\n.sub-tab-container .sub-tab a:hover,\n.sub-tab-container .sub-tab button:hover {\n color: #000 !important;\n}\n\n.sub-tab-container .sub-tab.active a,\n.sub-tab-container .sub-tab.active button {\n color: #000 !important;\n font-weight: 600 !important;\n border-bottom: 2px solid #000 !important;\n background-color: transparent !important;\n border-radius: 0 !important;\n}\n\n/* \u041A\u041B\u042E\u0427\u0415\u0412\u041E\u0415 \u0418\u0421\u041F\u0420\u0410\u0412\u041B\u0415\u041D\u0418\u0415: \u0443\u0431\u0440\u0430\u0442\u044C padding \u0443 form-section \u0441 \u0441\u0443\u0431\u0442\u0430\u0431\u0430\u043C\u0438 */\n.form-section:has(.sub-tab-container) {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n margin-bottom: 0 !important;\n}\n\n/* \u0421\u043A\u0440\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u0435 section-body */\n.form-section .section-body:empty,\n.form-section .section-body:has(> .form-column > form > .frappe-control[data-fieldname=\"html_vohk\"] > span.tooltip-content:only-child) {\n display: none !important;\n padding: 0 !important;\n margin: 0 !important;\n min-height: 0 !important;\n}\n\n/* \u0418\u0441\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0443 sub-tab-container */\n.sub-tab-container.nav {\n display: flex;\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.sub-tab-container.nav > li[style*=\"display: none\"] {\n display: none !important;\n}\n\n/* \u041A\u043B\u0430\u0441\u0441 \u0434\u043B\u044F \u0441\u043A\u0440\u044B\u0442\u0438\u044F \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430 \u0441\u0443\u0431\u0442\u0430\u0431\u043E\u0432 */\n.sub-tab-container.subtab-hidden {\n display: none !important;\n visibility: hidden !important;\n pointer-events: none !important;\n height: 0 !important;\n overflow: hidden !important;\n}"],
|
||||
"mappings": "AAAA,0DAGI,uBACA,qCACA,uCAGJ,4BARA,mBAUI,0BACA,sBAXJ,oBAgBA,iEAEI,+BACA,wBAnBJ,2BAqBI,qBACA,sBACA,yBACA,0BAxBJ,mBA0BI,iCACA,0BAGJ,6EAEI,qBAGJ,+EAEI,qBACA,0BACA,uCACA,uCAxCJ,0BA6CA,sCACI,wBACA,2BACA,0BAIJ,yKAEI,uBAtDJ,uCAyDI,uBAIJ,uBACI,aACA,gBA/DJ,mBAoEA,kDACI,uBAIJ,iCACI,uBACA,4BACA,8BACA,mBACA",
|
||||
"names": []
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
(()=>{function x(a,t,n){var m;if(!t||!t.length)return;let i=(m=frappe.boot.tab_hierarchy)==null?void 0:m[n];if(!i)return;let e=i.subtabs||{};if(Object.keys(e).length===0||(D(a),t.attr("data-subtabs-processed")==="true"))return;let s=c=>typeof __=="function"?__(c):c,o=t.find("ul.form-tabs > li.nav-item");o.each((c,k)=>{let I=$(k).text().trim(),E=Object.keys(e).find(f=>s(f)===I);if(!E)return;let p=_(k).attr("aria-controls"),d=$(document.getElementById(p));if(!d.length)return;let r=d.find(".form-section.card-section");r.length||(r=d.find(".form-section")),r.length||(r=d.children().first()),r.length||(r=d);let b=r.find("> .sub-tab-container");b.length||(b=$(`<div class="sub-tab-container nav" data-parent-id="${p}"></div>`),r.prepend(b)),e[E].forEach(f=>{let h=o.filter((P,j)=>$(j).text().trim()===s(f));if(!h.length)return;let l=h.clone(!0);l.addClass("sub-tab"),l.attr("data-subtab-name",f),l.attr("data-parent-id",p);let v=_(l);v.off("click"),v.attr("data-target-id",v.attr("aria-controls")),b.append(l),h.remove()})}),t.on("click.customsubtabs",".sub-tab button.nav-link, .sub-tab a.nav-link",function(c){c.preventDefault(),c.stopPropagation(),O(a,t,$(this).closest(".sub-tab"))}),t.attr("data-subtabs-processed","true");let u=t.find("ul.form-tabs > li.nav-item .nav-link.active").first();y(t,[u.attr("aria-controls")])}function _(a){let t=$(a),n=t.find("button.nav-link");return n.length?n:t.find("a.nav-link")}function g(a){let t=_(a);return t.attr("data-target-id")||t.attr("aria-controls")}function L(a,t){let n=[],i=t;for(;i&&i.length;){n.unshift({pane_id:g(i),parent_id:i.attr("data-parent-id")});let e=i.attr("data-parent-id");i=a.find(".sub-tab").filter((s,o)=>g($(o))===e)}return{chain:n,root_id:n.length?n[0].parent_id:null}}function O(a,t,n){let i=L(t,n);if(!i.root_id)return;a.__active_subtab=i;let e=(a.layout.tabs||[]).find(s=>s.wrapper.attr("id")===i.root_id);e?e.set_active():C(a,t,i)}function C(a,t,n){let i=[n.root_id];for(let e of n.chain){let s=document.getElementById(e.pane_id),o=document.getElementById(e.parent_id);!s||!o||(s.classList.add("active","show"),s.classList.remove("hide"),s.previousElementSibling!==o&&o.after(s),i.push(e.pane_id))}y(t,i),B(t,n.chain.map(e=>e.pane_id))}function T(a,t,n){delete a.__active_subtab,y(t,[n]),B(t,[])}function y(a,t){a.find(".sub-tab-container").each(function(){let n=$(this).attr("data-parent-id");$(this).toggleClass("subtab-hidden",!t.includes(n))})}function B(a,t){a.find(".sub-tab").each(function(){let n=t.includes(g($(this)));$(this).toggleClass("active",n),_($(this)).toggleClass("active",n).attr("aria-selected",n?"true":"false")})}function D(a){let t=a.layout&&a.layout.tabs;if(!t||!t.length)return;let n=t[0].constructor;if(n.prototype.__subtabs_patched)return;let i=n.prototype.set_active;n.prototype.set_active=function(){i.apply(this,arguments);let e=this.frm,s=e&&e.layout&&e.layout.wrapper;if(!s||s.attr("data-subtabs-processed")!=="true")return;let o=this.wrapper&&this.wrapper.attr("id");if(!o)return;let u=e.__active_subtab;if(!u||u.root_id!==o){T(e,s,o);return}C(e,s,u)},n.prototype.__subtabs_patched=!0}frappe.ui.form.on("*",{refresh:function(a){setTimeout(()=>{var t;x(a,((t=a.layout)==null?void 0:t.wrapper)||a.$wrapper,a.doctype)},500)}});})();
|
||||
//# sourceMappingURL=custom_subtabs.bundle.QW446NRW.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,235 +0,0 @@
|
|||
// Subtabs: nest Frappe form tabs one or two levels deep.
|
||||
//
|
||||
// A Tab Break with `js_parent_subtab` set is a subtab. Its nav item is moved out of
|
||||
// the main tab bar and into a secondary bar rendered inside its parent's pane.
|
||||
//
|
||||
// Frappe's tab model has no idea any of this exists. `Tab.set_active()` (form/tab.js)
|
||||
// strips `show active` off *every* pane except the one it is activating, and
|
||||
// `Layout.set_tab_as_active()` calls it on every `refresh_sections()` — which Frappe
|
||||
// runs on every `frm.refresh_field()` and on every value change. So the moment a
|
||||
// field changed, the open subtab's pane was stripped and its content vanished; the
|
||||
// content appeared to flash for a fraction of a second and disappear.
|
||||
//
|
||||
// The fix is to stop fighting Frappe's model and instead ride on it:
|
||||
//
|
||||
// * clicking a subtab registers its top-level ANCESTOR as the active tab, so
|
||||
// Frappe's own bookkeeping (frm.active_tab_map, the URL hash) stays coherent and
|
||||
// keeps re-activating the right tab;
|
||||
// * `Tab.set_active` is wrapped so that once Frappe has finished re-activating that
|
||||
// ancestor, we re-assert the rest of the chain.
|
||||
//
|
||||
// The whole ancestor chain has to be re-asserted, not just the leaf: with two levels
|
||||
// of nesting, the top pane holds the first subtab bar, the middle pane holds the
|
||||
// second, and the leaf pane holds the content. All three must be `active show` at
|
||||
// once — something Frappe's one-tab-at-a-time model will never do on its own.
|
||||
|
||||
function setup_custom_subtabs(frm, wrapper, currentDoctype) {
|
||||
if (!wrapper || !wrapper.length) return;
|
||||
|
||||
const hierarchy = frappe.boot.tab_hierarchy?.[currentDoctype];
|
||||
if (!hierarchy) return;
|
||||
|
||||
const subtabs = hierarchy.subtabs || {};
|
||||
if (Object.keys(subtabs).length === 0) return;
|
||||
|
||||
patch_tab_activation(frm);
|
||||
|
||||
if (wrapper.attr("data-subtabs-processed") === "true") return;
|
||||
|
||||
// tab_hierarchy stores raw (English) labels, while Frappe renders tab labels
|
||||
// through __(), so the DOM text is already translated. Compare through the
|
||||
// translation; keep the English label in the internal bookkeeping.
|
||||
const tr = (s) => (typeof __ === "function" ? __(s) : s);
|
||||
|
||||
const tabs = wrapper.find("ul.form-tabs > li.nav-item");
|
||||
|
||||
tabs.each((_, tab) => {
|
||||
const label = $(tab).text().trim();
|
||||
const parent_key = Object.keys(subtabs).find((k) => tr(k) === label);
|
||||
if (!parent_key) return;
|
||||
|
||||
const parent_pane_id = tab_link(tab).attr("aria-controls");
|
||||
const parent_pane = $(document.getElementById(parent_pane_id));
|
||||
if (!parent_pane.length) return;
|
||||
|
||||
let section = parent_pane.find(".form-section.card-section");
|
||||
if (!section.length) section = parent_pane.find(".form-section");
|
||||
if (!section.length) section = parent_pane.children().first();
|
||||
if (!section.length) section = parent_pane;
|
||||
|
||||
let bar = section.find("> .sub-tab-container");
|
||||
if (!bar.length) {
|
||||
bar = $(`<div class="sub-tab-container nav" data-parent-id="${parent_pane_id}"></div>`);
|
||||
section.prepend(bar);
|
||||
}
|
||||
|
||||
subtabs[parent_key].forEach((child) => {
|
||||
const original = tabs.filter((__, el) => $(el).text().trim() === tr(child));
|
||||
if (!original.length) return;
|
||||
|
||||
const clone = original.clone(true);
|
||||
clone.addClass("sub-tab");
|
||||
clone.attr("data-subtab-name", child);
|
||||
clone.attr("data-parent-id", parent_pane_id);
|
||||
|
||||
// clone(true) copies Frappe's own click handler (tab.js binds set_active on
|
||||
// the nav-link), which would deactivate the parent pane — the very pane this
|
||||
// button lives in. Subtab clicks are handled by our delegated handler below.
|
||||
const link = tab_link(clone);
|
||||
link.off("click");
|
||||
link.attr("data-target-id", link.attr("aria-controls"));
|
||||
|
||||
bar.append(clone);
|
||||
original.remove();
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.on("click.customsubtabs", ".sub-tab button.nav-link, .sub-tab a.nav-link", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activate_subtab(frm, wrapper, $(this).closest(".sub-tab"));
|
||||
});
|
||||
|
||||
wrapper.attr("data-subtabs-processed", "true");
|
||||
|
||||
// Whatever tab Frappe already considers active decides which subtab bar shows.
|
||||
const active = wrapper.find("ul.form-tabs > li.nav-item .nav-link.active").first();
|
||||
sync_bars(wrapper, [active.attr("aria-controls")]);
|
||||
}
|
||||
|
||||
function tab_link(el) {
|
||||
const $el = $(el);
|
||||
const button = $el.find("button.nav-link");
|
||||
return button.length ? button : $el.find("a.nav-link");
|
||||
}
|
||||
|
||||
function pane_id_of(subtab) {
|
||||
const link = tab_link(subtab);
|
||||
return link.attr("data-target-id") || link.attr("aria-controls");
|
||||
}
|
||||
|
||||
// Walk from a subtab up to its top-level tab. Returns the panes to re-assert,
|
||||
// outermost first, plus the id of the real Frappe tab that roots the chain.
|
||||
function ancestry(wrapper, subtab) {
|
||||
const chain = [];
|
||||
let node = subtab;
|
||||
|
||||
while (node && node.length) {
|
||||
chain.unshift({ pane_id: pane_id_of(node), parent_id: node.attr("data-parent-id") });
|
||||
|
||||
const parent_id = node.attr("data-parent-id");
|
||||
node = wrapper.find(".sub-tab").filter((_, el) => pane_id_of($(el)) === parent_id);
|
||||
}
|
||||
|
||||
return { chain: chain, root_id: chain.length ? chain[0].parent_id : null };
|
||||
}
|
||||
|
||||
function activate_subtab(frm, wrapper, subtab) {
|
||||
const state = ancestry(wrapper, subtab);
|
||||
if (!state.root_id) return;
|
||||
|
||||
frm.__active_subtab = state;
|
||||
|
||||
// Hand the activation to Frappe, naming the top-level ancestor. Frappe records it
|
||||
// in frm.active_tab_map and the URL hash, so every later refresh re-activates the
|
||||
// same tab — and our wrapper puts the rest of the chain back on top of it.
|
||||
const root = (frm.layout.tabs || []).find((t) => t.wrapper.attr("id") === state.root_id);
|
||||
if (root) {
|
||||
root.set_active();
|
||||
} else {
|
||||
reassert_chain(frm, wrapper, state);
|
||||
}
|
||||
}
|
||||
|
||||
function reassert_chain(frm, wrapper, state) {
|
||||
const visible = [state.root_id];
|
||||
|
||||
for (const step of state.chain) {
|
||||
const pane = document.getElementById(step.pane_id);
|
||||
const parent = document.getElementById(step.parent_id);
|
||||
if (!pane || !parent) continue;
|
||||
|
||||
pane.classList.add("active", "show");
|
||||
pane.classList.remove("hide");
|
||||
|
||||
// A subtab's pane can sit before its parent's in the DOM, depending on
|
||||
// field_order. Left alone, the subtab bar renders below its own content and
|
||||
// looks like it disappeared. Keep the pane directly after its parent.
|
||||
if (pane.previousElementSibling !== parent) {
|
||||
parent.after(pane);
|
||||
}
|
||||
|
||||
visible.push(step.pane_id);
|
||||
}
|
||||
|
||||
sync_bars(wrapper, visible);
|
||||
sync_buttons(wrapper, state.chain.map((s) => s.pane_id));
|
||||
}
|
||||
|
||||
function clear_subtabs(frm, wrapper, active_id) {
|
||||
delete frm.__active_subtab;
|
||||
sync_bars(wrapper, [active_id]);
|
||||
sync_buttons(wrapper, []);
|
||||
}
|
||||
|
||||
// A subtab bar belongs to a pane; show it only while that pane is on screen.
|
||||
function sync_bars(wrapper, visible_pane_ids) {
|
||||
wrapper.find(".sub-tab-container").each(function () {
|
||||
const owner = $(this).attr("data-parent-id");
|
||||
$(this).toggleClass("subtab-hidden", !visible_pane_ids.includes(owner));
|
||||
});
|
||||
}
|
||||
|
||||
function sync_buttons(wrapper, active_pane_ids) {
|
||||
wrapper.find(".sub-tab").each(function () {
|
||||
const on = active_pane_ids.includes(pane_id_of($(this)));
|
||||
$(this).toggleClass("active", on);
|
||||
tab_link($(this)).toggleClass("active", on).attr("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
// Wrap Frappe's Tab.set_active once per session. Everything that re-activates a tab —
|
||||
// a click, a refresh_sections(), a refresh_field() — funnels through it, so this is
|
||||
// the one place that can put the subtab chain back after Frappe has torn it down.
|
||||
function patch_tab_activation(frm) {
|
||||
const tabs = frm.layout && frm.layout.tabs;
|
||||
if (!tabs || !tabs.length) return;
|
||||
|
||||
const Tab = tabs[0].constructor;
|
||||
if (Tab.prototype.__subtabs_patched) return;
|
||||
|
||||
const set_active = Tab.prototype.set_active;
|
||||
|
||||
Tab.prototype.set_active = function () {
|
||||
set_active.apply(this, arguments);
|
||||
|
||||
const form = this.frm;
|
||||
const wrapper = form && form.layout && form.layout.wrapper;
|
||||
if (!wrapper || wrapper.attr("data-subtabs-processed") !== "true") return;
|
||||
|
||||
const activated = this.wrapper && this.wrapper.attr("id");
|
||||
if (!activated) return;
|
||||
|
||||
const state = form.__active_subtab;
|
||||
|
||||
// Activating anything other than the chain's root means the user left the
|
||||
// subtab — or Frappe restored a different tab. Either way the selection is over.
|
||||
if (!state || state.root_id !== activated) {
|
||||
clear_subtabs(form, wrapper, activated);
|
||||
return;
|
||||
}
|
||||
|
||||
reassert_chain(form, wrapper, state);
|
||||
};
|
||||
|
||||
Tab.prototype.__subtabs_patched = true;
|
||||
}
|
||||
|
||||
frappe.ui.form.on("*", {
|
||||
refresh: function (frm) {
|
||||
// The layout is rebuilt after refresh; let it settle before rewiring the bars.
|
||||
setTimeout(() => {
|
||||
setup_custom_subtabs(frm, frm.layout?.wrapper || frm.$wrapper, frm.doctype);
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
frappe.ui.form.Layout.prototype.setup_tab_events = function () {
|
||||
setTimeout(() => {
|
||||
const tabs = this.wrapper.find("ul.form-tabs > li.nav-item");
|
||||
const currentDoctype = cur_frm.doctype;
|
||||
const hierarchy = frappe.boot.tab_hierarchy[currentDoctype];
|
||||
|
||||
if (!hierarchy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subtabs = hierarchy.subtabs || {};
|
||||
|
||||
// Function to show tab content
|
||||
function showTabContent(tabId) {
|
||||
const tabPane = document.getElementById(tabId);
|
||||
if (tabPane) {
|
||||
tabPane.style.display = 'block';
|
||||
tabPane.classList.add('active', 'show');
|
||||
}
|
||||
}
|
||||
|
||||
// Function to hide all tab contents
|
||||
function hideAllTabContents() {
|
||||
$(".form-tab-content > .tab-pane").each(function () {
|
||||
$(this).removeClass("active show").css("display", "none");
|
||||
});
|
||||
}
|
||||
|
||||
// Process subtabs
|
||||
tabs.each((index, tab) => {
|
||||
const tabLabel = $(tab).text().trim();
|
||||
|
||||
if (subtabs[tabLabel]) {
|
||||
const tabContentId = $(tab).find("a").attr("aria-controls");
|
||||
const tabContent = $(`#${tabContentId}`);
|
||||
|
||||
if (!tabContent.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const possibleSelectors = [
|
||||
".row.form-section.card-section.visible-section",
|
||||
".form-section",
|
||||
".row.form-section",
|
||||
".card-section",
|
||||
".visible-section",
|
||||
".row",
|
||||
".section",
|
||||
"div[class*='section']",
|
||||
"div[class*='form']"
|
||||
];
|
||||
|
||||
let sectionContainer = null;
|
||||
|
||||
possibleSelectors.forEach(selector => {
|
||||
const found = tabContent.find(selector);
|
||||
if (found.length > 0 && !sectionContainer) {
|
||||
sectionContainer = found.first();
|
||||
}
|
||||
});
|
||||
|
||||
if (!sectionContainer || sectionContainer.length === 0) {
|
||||
sectionContainer = tabContent;
|
||||
}
|
||||
|
||||
// Ищем контейнер субтабов рядом с основными табами, а не внутри контента
|
||||
const mainTabsContainer = this.wrapper.find("ul.form-tabs");
|
||||
let subtabContainer = mainTabsContainer.siblings('.sub-tab-container[data-parent-id="' + tabContentId + '"]');
|
||||
|
||||
if (!subtabContainer.length) {
|
||||
// Создаем контейнер субтабов после основных табов
|
||||
subtabContainer = $("<div class='sub-tab-container nav' data-parent-id='" + tabContentId + "' style='display: none;'></div>");
|
||||
mainTabsContainer.after(subtabContainer);
|
||||
}
|
||||
|
||||
subtabs[tabLabel].forEach((subtab) => {
|
||||
const subtabElement = tabs.filter((_, el) => $(el).text().trim() === subtab);
|
||||
|
||||
if (subtabElement.length) {
|
||||
const subtabClone = subtabElement.clone();
|
||||
subtabClone.addClass("sub-tab");
|
||||
subtabClone.find("a").removeAttr("href");
|
||||
subtabClone.attr("data-parent-id", tabContentId);
|
||||
|
||||
subtabContainer.append(subtabClone);
|
||||
subtabElement.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle main tab activation (исключаем субтабы)
|
||||
$(".nav-item:not(.sub-tab)").off("click.subtabs").on("click.subtabs", function () {
|
||||
const clickedTab = $(this);
|
||||
const tabId = clickedTab.find("a").attr("aria-controls");
|
||||
|
||||
hideAllTabContents();
|
||||
|
||||
const tabContent = document.querySelector(`#${tabId}`);
|
||||
if (tabContent) {
|
||||
$(tabContent).addClass("active show").css("display", "block");
|
||||
}
|
||||
|
||||
$(".nav-item").removeClass("active");
|
||||
clickedTab.addClass("active");
|
||||
|
||||
// Скрываем все контейнеры субтабов
|
||||
$(".sub-tab-container").css("display", "none");
|
||||
|
||||
// Показываем контейнер субтабов для активного таба
|
||||
$(".sub-tab-container").each(function () {
|
||||
const containerParentId = $(this).attr("data-parent-id");
|
||||
|
||||
if (containerParentId === tabId) {
|
||||
$(this).css("display", "flex");
|
||||
$(this).find(".sub-tab").removeClass("active");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle subtab activation (упрощенная логика)
|
||||
$(document).off("click.subtabs", ".sub-tab").on("click.subtabs", ".sub-tab", function (event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const clickedSubtab = $(this);
|
||||
const subtabId = clickedSubtab.find("a").attr("aria-controls");
|
||||
const parentTabId = clickedSubtab.attr("data-parent-id");
|
||||
|
||||
// Делаем активным только кликнутый субтаб в этом контейнере
|
||||
clickedSubtab.siblings(".sub-tab").removeClass("active");
|
||||
clickedSubtab.addClass("active");
|
||||
|
||||
// Скрываем все контенты табов
|
||||
hideAllTabContents();
|
||||
|
||||
// Показываем контент субтаба
|
||||
const subtabContent = $(`#${subtabId}`);
|
||||
|
||||
if (subtabContent.length) {
|
||||
subtabContent.addClass("active show").css("display", "block");
|
||||
}
|
||||
|
||||
// Оставляем родительский таб активным и видимым
|
||||
$(".nav-item:not(.sub-tab)").removeClass("active");
|
||||
const parentTab = $(`.nav-item:not(.sub-tab) a[aria-controls="${parentTabId}"]`).closest('.nav-item');
|
||||
if (parentTab.length) {
|
||||
parentTab.addClass("active");
|
||||
}
|
||||
|
||||
// Убеждаемся что контейнер субтабов остается видимым
|
||||
const subtabContainer = clickedSubtab.closest('.sub-tab-container');
|
||||
if (subtabContainer.length) {
|
||||
subtabContainer.css("display", "flex");
|
||||
}
|
||||
});
|
||||
|
||||
// Проверяем активную вкладку при загрузке страницы
|
||||
let activeTab = $(".nav-item:not(.sub-tab).active");
|
||||
|
||||
// Способ 2: Поиск по активному контенту
|
||||
if (!activeTab.length) {
|
||||
const activeContent = $(".form-tab-content > .tab-pane.active");
|
||||
|
||||
if (activeContent.length) {
|
||||
const activeContentId = activeContent.attr('id');
|
||||
activeTab = $(`.nav-item a[aria-controls="${activeContentId}"]`).closest('.nav-item');
|
||||
}
|
||||
}
|
||||
|
||||
// Способ 3: Поиск по показанному контенту
|
||||
if (!activeTab.length) {
|
||||
const visibleContent = $(".form-tab-content > .tab-pane:visible");
|
||||
|
||||
if (visibleContent.length) {
|
||||
const visibleContentId = visibleContent.first().attr('id');
|
||||
activeTab = $(`.nav-item a[aria-controls="${visibleContentId}"]`).closest('.nav-item');
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTab.length) {
|
||||
const activeTabId = activeTab.find("a").attr("aria-controls");
|
||||
|
||||
// Проверяем есть ли субтабы у активной вкладки
|
||||
$(".sub-tab-container").each(function () {
|
||||
const containerParentId = $(this).attr("data-parent-id");
|
||||
|
||||
if (containerParentId === activeTabId) {
|
||||
$(this).css("display", "flex");
|
||||
$(this).find(".sub-tab").removeClass("active");
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
Loading…
Reference in New Issue