custom_subtabs/ARCHITECTURE.md

32 KiB
Raw Blame History

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 для передачи данных на клиент
  • Регистрирует патчи для выполнения после миграции
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 вида:
    {
      "Customer": {
        "tabs": ["Details", "Settings"],
        "subtabs": {
          "Settings": ["Email Settings", "Privacy Settings"]
        }
      },
      "Item": {
        "tabs": ["Basic", "Pricing"],
        "subtabs": {
          "Pricing": ["Discounts", "Tax"]
        }
      }
    }
    
  4. Передает через bootinfo.tab_hierarchy на клиент

Пример структуры:

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. Добавляет новое поле:
    {
      "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
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('*')
frappe.ui.form.on('*', {
    refresh: function(frm) {
        setTimeout(() => {
            setup_custom_subtabs(frm);
        }, 500);
    }
});

Зачем два подхода:

  • Первый перехватывает встроенную логику Frappe
  • Второй использует event-based подход для совместимости с v16
  • Оба могут работать одновременно для максимальной совместимости
C. Функция setup_custom_subtabs(frm)

Основная логика:

  1. Получение данных:

    const currentDoctype = frm.doctype;
    const hierarchy = frappe.boot.tab_hierarchy?.[currentDoctype];
    const subtabs = hierarchy.subtabs || {};
    
  2. Поиск всех вкладок:

    const tabs = wrapper.find("ul.form-tabs > li.nav-item");
    
  3. Обработка каждой родительской вкладки:

    tabs.each((index, tab) => {
        const tabLabel = $(tab).text().trim();
    
        if (subtabs[tabLabel]) {
            // Это родительская вкладка с подвкладками
        }
    });
    
  4. Создание контейнера для подвкладок:

    subtabContainer = $("<div class='sub-tab-container nav' data-parent-id='" + tabContentId + "'></div>");
    sectionContainer.append(subtabContainer);
    
  5. Перемещение подвкладок:

    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)

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()

function hideAllTabContents() {
    $(".form-tab-content > .tab-pane").each(function () {
        $(this).removeClass("active show").css("display", "none");
    });
}

Назначение: Скрывает все содержимое вкладок перед показом выбранной.

findParentAndShowSubtabs(currentTabName)

function findParentAndShowSubtabs(currentTabName) {
    // 1. Найти родительскую вкладку для currentTabName
    // 2. Показать все подвкладки этой родительской вкладки
    // 3. Показать содержимое родительской вкладки
    // 4. Рекурсивно обработать родительскую вкладку (если она сама подвкладка)
}

Назначение: Рекурсивная функция для обработки многоуровневых вложенностей (sub-subtabs).

E. Обработчики событий

Клик по основной вкладке:

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");
        }
    });
});

Клик по подвкладке:

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

Стили для визуального оформления подвкладок.

Структура стилей:

.sub-tab-container {
    margin: 0 !important;
    padding: 0 !important;
    display: flex !important;
    justify-content: flex-start !important;
    border-bottom: 1px solid #ddd !important;
}

Назначение: Контейнер для подвкладок, отображается как flexbox для горизонтального расположения.

.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: 10px 15px !important;
    color: #555 !important;
    border: none !important;
    font-size: 14px !important;
    font-weight: normal !important;
}

Назначение: Стилизация ссылок и кнопок внутри подвкладок (поддержка v15/v16).

.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:

# Для 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:

{
    "Customer": {
        "tabs": ["Details", "Settings", "Contact"],
        "subtabs": {
            "Settings": ["Email Settings", "Privacy Settings"]
        }
    }
}

Шаг 3: Передача данных на клиент

bootinfo.tab_hierarchy = tab_hierarchy_data

Результат: На клиенте доступен frappe.boot.tab_hierarchy.

Шаг 4: Frontend обработка (отрисовка формы)

Когда пользователь открывает форму Customer:

  1. Frappe рендерит все вкладки как обычно:

    <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:

    <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 (подвкладка)

Конфигурация:

{
  "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> элемент

Универсальная реализация:

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. Установить приложение:

    bench get-app https://github.com/your-repo/custom_subtabs
    bench --site site_name install-app custom_subtabs
    
  2. Запустить миграцию:

    bench --site site_name migrate
    

    Это выполнит add_parent_field.py и добавит поле js_parent_subtab в DocField.

  3. Перезапустить bench:

    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:

const tabContentId = tabLink.attr("aria-controls");
// Пример: "customer-settings_tab"

Связь между nav-item и tab-pane:

<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 родительской вкладки:

<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 и показывает их.

Таймауты

setTimeout(() => {
    setup_custom_subtabs(frm);
}, 500);

Зачем: Frappe рендерит вкладки асинхронно. Таймаут дает время для завершения рендеринга перед обработкой подвкладок.

Event delegation

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:

.sub-tab-container .sub-tab.active button {
    color: #ff5733 !important; /* Красный цвет для активной подвкладки */
}

Поддержка вертикальных вкладок

Изменить .sub-tab-container:

.sub-tab-container {
    flex-direction: column !important;
}

Иконки для подвкладок

Добавить логику в JS:

const iconMapping = {
    "Email Settings": "fa-envelope",
    "Privacy Settings": "fa-lock"
};

subtabClone.prepend(`<i class="fa ${iconMapping[subtab]}"></i>`);

Отладка

Включение логов

Скрипт содержит множество console.log() для отладки:

console.log("Hierarchy for doctype:", hierarchy);
console.log("Processing parent tab:", tabLabel);
console.log("Looking for subtab:", subtab, "found:", subtabElement.length);

Открыть консоль браузера (F12) и проверить логи при загрузке формы.

Проверка tab_hierarchy

В консоли браузера:

console.log(frappe.boot.tab_hierarchy);

Ожидаемый результат:

{
  "Customer": {
    "tabs": ["Details", "Settings"],
    "subtabs": {
      "Settings": ["Email Settings", "Privacy Settings"]
    }
  }
}

Проверка DOM структуры

// Проверить все контейнеры подвкладок
$(".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.


Контакты