// Shared function for setting up subtabs function setup_custom_subtabs(wrapper, currentDoctype) { if (!wrapper || !wrapper.length) { return; } const tabs = wrapper.find("ul.form-tabs > li.nav-item"); const hierarchy = frappe.boot.tab_hierarchy?.[currentDoctype]; if (!hierarchy) { return; } const subtabs = hierarchy.subtabs || {}; if (Object.keys(subtabs).length === 0) { return; } // tab_hierarchy хранит СЫРЫЕ (английские) label'ы, а Frappe рисует вкладки // через __(), т.е. в DOM текст уже переведён (AZ/RU). Поэтому сопоставлять // вкладку из DOM с ключом иерархии нужно через перевод этого ключа. // Внутренние структуры (childToParentMap, data-subtab-name) оставляем на // английском — переводим только на границе сравнения с DOM. const tr = (s) => (typeof __ === 'function' ? __(s) : s); // 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; } // Check if already processed if (wrapper.attr('data-subtabs-processed') === 'true') { return; } // Build parent-child map const childToParentMap = {}; for (const [parentTab, children] of Object.entries(subtabs)) { children.forEach(child => { childToParentMap[child] = parentTab; }); } // Process subtabs - move them into containers tabs.each((index, tab) => { const tabLabel = $(tab).text().trim(); // tabLabel — переведённый текст из DOM; ключ иерархии — английский. // Находим английский ключ-родитель, чей перевод совпал с DOM. const parentKey = Object.keys(subtabs).find((k) => tr(k) === tabLabel); if (parentKey) { const tabLink = getTabLink(tab); const tabContentId = tabLink.attr("aria-controls"); const tabContent = $(`#${tabContentId}`); if (!tabContent.length) { return; } // Find container let sectionContainer = tabContent.find(".form-section.card-section"); if (!sectionContainer.length) { sectionContainer = tabContent.find(".form-section"); } if (!sectionContainer.length) { sectionContainer = tabContent.children().first(); } if (!sectionContainer.length) { sectionContainer = tabContent; } let subtabContainer = sectionContainer.find('.sub-tab-container'); if (!subtabContainer.length) { subtabContainer = $("
"); sectionContainer.prepend(subtabContainer); } subtabs[parentKey].forEach((subtab) => { // subtab — английский; в DOM ищем по его переводу. const subtabElement = tabs.filter((_, el) => $(el).text().trim() === tr(subtab)); if (subtabElement.length) { const subtabClone = subtabElement.clone(true); subtabClone.addClass("sub-tab"); // data-subtab-name держим английским — childToParentMap/ensureParentChainVisible на нём. subtabClone.attr("data-subtab-name", subtab); const clonedLink = getTabLink(subtabClone); clonedLink.removeAttr("href"); clonedLink.attr("data-target-id", clonedLink.attr("aria-controls")); // clone(true) копирует прямой обработчик Frappe (tab.js: nav-link.on('click') -> // set_active()), который деактивирует РОДИТЕЛЬСКУЮ панель — а в ней лежат сами // кнопки субтабов. Снимаем его: кликами по субтабам управляем своей делегацией. clonedLink.off("click"); subtabClone.attr("data-parent-id", tabContentId); subtabContainer.append(subtabClone); subtabElement.remove(); } }); } }); // Function to show parent chain function ensureParentChainVisible(subtabName) { let currentChild = subtabName; const parentsToShow = []; // Collect parent chain while (childToParentMap[currentChild]) { const parentName = childToParentMap[currentChild]; parentsToShow.push(parentName); // Show all siblings - НЕ используем .show(), используем CSS класс const siblings = subtabs[parentName] || []; siblings.forEach(sibling => { const siblingElement = wrapper.find(`.sub-tab[data-subtab-name="${sibling}"]`); siblingElement.css('display', ''); // Убираем inline style }); currentChild = parentName; } // Show all parent content parentsToShow.forEach(parentName => { const parentTabContentId = `${currentDoctype.toLowerCase().replace(/\s+/g, '-')}-${parentName.toLowerCase().replace(/\s+/g, '_')}_tab`; const parentContent = $(`#${parentTabContentId}`); if (parentContent.length && !parentContent.hasClass('active')) { parentContent.addClass('active show').css('display', 'block'); } }); } // Handle subtab clicks wrapper.on("click.customsubtabs", ".sub-tab button.nav-link, .sub-tab a.nav-link", function(event) { event.preventDefault(); event.stopPropagation(); 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 parentContainerId = clickedSubtab.attr("data-parent-id"); // 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"); // Скрыть все контейнеры субтабов, кроме родительского wrapper.find(".sub-tab-container").each(function() { const containerParentId = $(this).attr("data-parent-id"); if (containerParentId !== parentContainerId) { $(this).addClass("subtab-hidden"); } else { $(this).removeClass("subtab-hidden"); } }); // 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"); } // Надёжно вернуть РОДИТЕЛЬСКУЮ панель (в ней живут кнопки субтабов) по её реальному // id из data-parent-id — НЕ реконструируя id из перевода label'а. Это и есть защита // от «все субтабы пропали»: панель с кнопками всегда восстанавливается. const parentPane = $(`#${parentContainerId}`); if (parentPane.length) { parentPane.addClass("active show").css("display", "block"); // Панель-контент субтаба может стоять в DOM РАНЬШЕ родительской панели // (зависит от field_order доктайпа). Тогда бар субтабов визуально уезжает ВНИЗ // под контент и кажется, что субтабы пропали. Ставим активный контент сразу // после родительской панели — бар всегда оказывается над ним. if (targetPane.length) { parentPane.after(targetPane); } } // Show parent chain (для вложенности глубже одного уровня) ensureParentChainVisible(subtabName); }); // Function to setup main tab handler function setupMainTabHandler() { // Для Frappe v16 используем MutationObserver вместо событий Bootstrap // так как shown.bs.tab не генерируется // Удаляем старый observer если есть if (wrapper.data('subtabs-observer')) { wrapper.data('subtabs-observer').disconnect(); } // Создаем MutationObserver для отслеживания изменений класса active const observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { const button = mutation.target; const $button = $(button); // Проверяем что это кнопка таба и она стала активной if ($button.hasClass('nav-link') && $button.hasClass('active')) { const li = $button.closest("li.nav-item"); // Игнорируем субтабы if (li.hasClass("sub-tab")) { return; } const tabId = button.getAttribute("aria-controls"); // ИСПРАВЛЕНИЕ: Убираем 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"); // Скрываем контент всех субтабов 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"); if (containerParentId === tabId) { $(this).removeClass("subtab-hidden"); } else { $(this).addClass("subtab-hidden"); } }); } } }); }); // Наблюдаем за всеми кнопками табов (не субтабов) wrapper.find('ul.form-tabs > li.nav-item:not(.sub-tab) button.nav-link, ul.form-tabs > li.nav-item:not(.sub-tab) a.nav-link').each(function() { observer.observe(this, { attributes: true, attributeFilter: ['class'] }); }); // Сохраняем observer чтобы можно было удалить позже wrapper.data('subtabs-observer', observer); } // Initialize handler setupMainTabHandler(); // Инициализация: скрыть все контейнеры, кроме активного таба const activeMainTab = wrapper.find("ul.form-tabs > li.nav-item.active:not(.sub-tab)").first(); const activeTabLink = getTabLink(activeMainTab); const activeTabId = activeTabLink.attr("aria-controls"); wrapper.find(".sub-tab-container").each(function() { const containerParentId = $(this).attr("data-parent-id"); if (containerParentId !== activeTabId) { $(this).addClass("subtab-hidden"); } }); // Mark as processed wrapper.attr('data-subtabs-processed', 'true'); } // Use form refresh event frappe.ui.form.on('*', { refresh: function(frm) { setTimeout(() => { const wrapper = frm.layout?.wrapper || frm.$wrapper; setup_custom_subtabs(wrapper, frm.doctype); }, 500); }, onload: function(frm) { // Form onload hook } });