diff --git a/formula_editor/api.py b/formula_editor/api.py index c0829c2..3b8f1c8 100644 --- a/formula_editor/api.py +++ b/formula_editor/api.py @@ -735,10 +735,11 @@ def get_report_filters_dynamic(report_name): # Находим и обновляем фильтр компании или добавляем его, если отсутствует company_filter = next((f for f in formatted_filters if f['fieldname'] == 'company'), None) - + if company_filter: company_filter['default'] = default_company company_filter['hidden'] = 1 # Скрываем фильтр компании всегда + company_filter['reqd'] = 0 # Важно! Не делаем его обязательным для пользовательского ввода else: # Добавляем скрытый фильтр компании, если его нет formatted_filters.append({ @@ -747,10 +748,10 @@ def get_report_filters_dynamic(report_name): 'fieldtype': 'Link', 'options': 'Company', 'default': default_company, - 'reqd': 1, + 'reqd': 0, # Не делаем обязательным 'hidden': 1 # Скрываем фильтр компании всегда }) - + return formatted_filters except Exception as e: diff --git a/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js index afefb4f..f405114 100644 --- a/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js +++ b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js @@ -131,6 +131,9 @@ function generate_client_script(frm) { let table_doctypes = {}; let refreshHandlers = []; // Добавим это для хранения обработчиков refresh + // Хранилище для объединения триггеров + let triggerHandlers = {}; + // Собираем информацию о табличных триггерах - ключ: table_field, значение: массив полей основного доктайпа const table_field_triggers = {}; @@ -180,84 +183,95 @@ function generate_client_script(frm) { // Добавляем функцию для обработки и выполнения формул functions.push(` // Функция для обработки и выполнения формул - async function processFormula(frm, formula) { - try { - // Проверяем наличие вызовов отчетов - if (formula.includes("report(")) { - // Извлекаем все вызовы report() из формулы - const reportRegex = /report\\(([^)]+)\\)/g;\n; - let match; - let processedFormula = formula; - - // Массив для хранения промисов запросов к отчетам - const reportPromises = []; - const reportPlaceholders = []; - - // Находим все вызовы report() и заменяем их на плейсхолдеры - while ((match = reportRegex.exec(formula)) !== null) { - const fullMatch = match[0]; // report(fieldName) - const fieldName = match[1]; // fieldName + async function processFormula(frm, formula) { + try { + // Проверяем наличие вызовов отчетов + if (formula.includes("report(")) { + // Извлекаем все вызовы report() из формулы + const reportRegex = /report\\(([^)]+)\\)/g; + let match; + let processedFormula = formula; - // Создаем уникальный плейсхолдер - const placeholder = \`__REPORT_PLACEHOLDER_\${reportPromises.length}__\`; + // Массив для хранения промисов запросов к отчетам + const reportPromises = []; + const reportPlaceholders = []; - // Ищем данные отчета в хранилище - const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); - const reportField = Object.values(reportFields).find(f => f.name === fieldName); - - if (reportField) { - // Добавляем промис запроса к отчету - reportPromises.push( - getReportFieldValue( - reportField.report, - JSON.parse(reportField.filters_json || "{}"), - reportField.field_name, - reportField.aggregation || 'sum' - ) - ); + // Находим все вызовы report() и заменяем их на плейсхолдеры + while ((match = reportRegex.exec(formula)) !== null) { + const fullMatch = match[0]; // report(fieldName) + const fieldName = match[1]; // fieldName - // Запоминаем соответствие плейсхолдера и вызова - reportPlaceholders.push({ - placeholder: placeholder, - original: fullMatch - }); + // Создаем уникальный плейсхолдер + const placeholder = \`__REPORT_PLACEHOLDER_\${reportPromises.length}__\`; - // Заменяем вызов отчета на плейсхолдер - processedFormula = processedFormula.replace(fullMatch, placeholder); + // Ищем данные отчета в хранилище + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + const reportField = Object.values(reportFields).find(f => f.name === fieldName); + + if (reportField) { + // Добавляем промис запроса к отчету + reportPromises.push( + getReportFieldValue( + frm, + reportField.report, + JSON.parse(reportField.filters_json || "{}"), + reportField.field_name, + reportField.aggregation || 'sum' + ) + ); + + // Запоминаем соответствие плейсхолдера и вызова + reportPlaceholders.push({ + placeholder: placeholder, + original: fullMatch + }); + + // Заменяем вызов отчета на плейсхолдер + processedFormula = processedFormula.replace(fullMatch, placeholder); + } + } + + // Если нашли вызовы отчетов, ждем выполнения всех запросов + if (reportPromises.length > 0) { + const reportResults = await Promise.all(reportPromises); + + // Заменяем плейсхолдеры на результаты + for (let i = 0; i < reportPlaceholders.length; i++) { + // Убедимся, что результат - числовое значение + const reportValue = reportResults[i] !== undefined ? Number(reportResults[i]) : 0; + processedFormula = processedFormula.replace( + reportPlaceholders[i].placeholder, + reportValue + ); + } + + // Теперь формула не содержит вызовов report(), обрабатываем её обычным способом + let transformResult = transform_expression(processedFormula, false); + + // Если есть внешняя трансформация, применяем её + let result; + if (transformResult && transformResult.transformed) { + result = eval(transformResult.transformed); + } else { + // Иначе просто вычисляем выражение + result = eval(processedFormula); + } + + // Возвращаем результат + return isNaN(result) ? 0 : result; } } - // Если нашли вызовы отчетов, ждем выполнения всех запросов - if (reportPromises.length > 0) { - const reportResults = await Promise.all(reportPromises); - - // Заменяем плейсхолдеры на результаты - for (let i = 0; i < reportPlaceholders.length; i++) { - processedFormula = processedFormula.replace( - reportPlaceholders[i].placeholder, - reportResults[i] !== undefined ? reportResults[i] : 0 - ); - } - - // Теперь формула не содержит вызовов report(), обрабатываем её обычным способом - let transformResult = transform_expression(processedFormula, false); - let result = eval(transformResult.transformed); - - // Возвращаем результат - return isNaN(result) ? 0 : result; - } + // Стандартная обработка для формул без отчетов + let transformResult = transform_expression(formula, false); + let result = eval(transformResult.transformed); + return isNaN(result) ? 0 : result; + } catch (e) { + console.error("Error processing formula:", formula, e); + return 0; } - - // Стандартная обработка для формул без отчетов - let transformResult = transform_expression(formula, false); - let result = eval(transformResult.transformed); - return isNaN(result) ? 0 : result; - } catch (e) { - console.error("Error processing formula:", formula, e); - return 0; - } }`); - + // Добавляем функцию для расчета универсальных полей functions.push(generateUniversalFieldsFunction(universal_fields)); @@ -267,15 +281,15 @@ function generate_client_script(frm) { } // Добавляем обработчики для ay и il - для расчета универсальных полей - main_script += ` "ay": async function(frm) { - // Вычисляем универсальные поля только когда меняется ay - await calculate_universal_fields(frm); - },\n`; - - main_script += ` "il": async function(frm) { - // Вычисляем универсальные поля только когда меняется il - await calculate_universal_fields(frm); - },\n`; + triggerHandlers["ay"] = { + isAsync: true, + handlers: ["// Вычисляем универсальные поля только когда меняется ay", "await calculate_universal_fields(frm);"] + }; + + triggerHandlers["il"] = { + isAsync: true, + handlers: ["// Вычисляем универсальные поля только когда меняется il", "await calculate_universal_fields(frm);"] + }; // Добавляем функцию преобразования выражений functions.push(generateTransformExpressionFunction(universal_fields, report_fields)); @@ -425,8 +439,19 @@ function generate_client_script(frm) { uniqueTriggers.forEach(trigger => { if (trigger) { - // Добавляем обработчик для обычного поля (всегда асинхронный) - main_script += ` "${trigger}": async function(frm) { await calculate_${fieldname}(frm); },\n`; + // Добавляем обработчик в хранилище триггеров + if (!triggerHandlers[trigger]) { + triggerHandlers[trigger] = { + isAsync: formula.includes("univ(") || formula.includes("report("), + handlers: [] + }; + } else if ((formula.includes("univ(") || formula.includes("report("))) { + // Если хотя бы один обработчик требует async, весь обработчик будет async + triggerHandlers[trigger].isAsync = true; + } + + // Добавляем вызов функции расчета + triggerHandlers[trigger].handlers.push(`await calculate_${fieldname}(frm);`); } }); } @@ -443,10 +468,10 @@ function generate_client_script(frm) { if (refreshHandlers.length > 0 || Object.keys(table_row_formulas).length > 0) { // Определяем, нужна ли асинхронная функция для refresh const needsAsync = refreshHandlers.some(h => h.uses_universal || h.uses_reports) || - Object.keys(table_row_formulas).some(id => { - const formula = table_row_formulas[id]; - return formula.formula.includes("univ(") || formula.formula.includes("report("); - }); + Object.keys(table_row_formulas).some(id => { + const formula = table_row_formulas[id]; + return formula.formula.includes("univ(") || formula.formula.includes("report("); + }); // Создаем обработчик refresh с вызовом всех нужных функций расчета let refreshHandlerBody = ` "refresh": ${needsAsync ? 'async ' : ''}function(frm) {\n`; @@ -454,23 +479,23 @@ function generate_client_script(frm) { // Если есть формулы для строк табличных частей, инициализируем их if (Object.keys(table_row_formulas).length > 0) { refreshHandlerBody += ` initialize_table_row_formulas(frm);\n`; + + // Добавляем явный вызов для каждой формулы строки с триггером "При открытии документа" + Object.keys(table_row_formulas).forEach(id => { + const formula = table_row_formulas[id]; + if (formula.triggers && formula.triggers.split(',').map(t => t.trim()).some(t => isPredefinedEvent(t))) { + const usesUniversal = formula.formula.includes("univ("); + const usesReports = formula.formula.includes("report("); + + if (usesUniversal || usesReports) { + refreshHandlerBody += ` await calculate_table_row_field(frm, "${formula.table_name}", ${parseInt(formula.row_index) - 1}, "${formula.field_name}", "${formula.formula}", "refresh");\n`; + } else { + refreshHandlerBody += ` calculate_table_row_field(frm, "${formula.table_name}", ${parseInt(formula.row_index) - 1}, "${formula.field_name}", "${formula.formula}", "refresh");\n`; + } + } + }); } - - // Добавляем вызовы всех обработчиков - const universalUsed = refreshHandlers.some(h => h.uses_universal); - if (universalUsed) { - refreshHandlerBody += ` await calculate_universal_fields(frm);\n`; - } - - // Асинхронно вызываем функции расчета полей - for (const handler of refreshHandlers) { - if (handler.uses_universal || handler.uses_reports) { - refreshHandlerBody += ` await calculate_${handler.field}(frm);\n`; - } else { - refreshHandlerBody += ` calculate_${handler.field}(frm);\n`; - } - } - + refreshHandlerBody += ' },\n'; // Добавляем обработчик refresh в начало скрипта @@ -478,8 +503,26 @@ function generate_client_script(frm) { 'frappe.ui.form.on("' + target_doctype + '", {\n' + refreshHandlerBody); } - // Добавляем обработчики для событий табличных частей - main_script = addTableRowFormulaHandlers(main_script, table_row_formulas, table_doctypes); + // Теперь создаем обработчики для каждого триггера + Object.keys(triggerHandlers).forEach(trigger => { + const handler = triggerHandlers[trigger]; + + main_script += ` "${trigger}": ${handler.isAsync ? 'async ' : ''}function(frm) { + ${handler.handlers.join('\n ')} + },\n`; + }); + + // Обработчики для табличных частей и триггеры, связанные с ними + const tableRowFormulaHandlers = processTableRowFormulas(table_row_formulas, table_doctypes); + Object.keys(tableRowFormulaHandlers).forEach(trigger => { + const handler = tableRowFormulaHandlers[trigger]; + + main_script += ` "${trigger}": ${handler.isAsync ? 'async ' : ''}function(frm) { + ${handler.isAsync ? 'await calculate_universal_fields(frm);' : '// Универсальные поля не используются'} + + ${handler.handlers.join('\n ')} + },\n`; + }); main_script += `});`; @@ -499,6 +542,19 @@ function generateReportFieldsFunction(report_fields) { // Функция для получения значения поля отчета async function getReportFieldValue(frm, reportName, filters, fieldName, aggregation = 'sum') { try { + + // Проверка: если используется $AyIlSpecial, но ay или il не заполнены — возвращаем 0 + if (Object.values(filters).some(v => v === "$AyIlSpecial")) { + if (!frm.doc["ay"] || !frm.doc["il"]) { + frappe.msgprint({ + title: "Not enough data", + message: "Please fill in the ay and il fields", + indicator: "orange" + }); + return 0; + } + } + // Обрабатываем фильтры с возможными ссылками на поля документа const processedFilters = {}; @@ -667,33 +723,70 @@ function generate_child_table_script(table_child_scripts, table_doctypes, table_ // Создаем общую функцию для расчета формул в строке таблицы let calcFunction = ` - ${usesUniversalFields ? 'async ' : ''}function calculate_${tableName}_row(frm, cdt, cdn) { + async function calculate_${tableName}_row(frm, cdt, cdn) { let row = locals[cdt][cdn]; - - try {`; - - // Если таблица использует универсальные поля, сначала вычисляем их - if (usesUniversalFields) { - calcFunction += ` - // Вычисляем универсальные поля - await calculate_universal_fields(frm); - `; - } - - // Добавляем расчеты для каждого поля табличной части - table_child_scripts[tableName].calculations.forEach(calc => { - calcFunction += ` - // Расчет для поля ${calc.field} - row["${calc.field}"] = eval(transform_expression("${calc.formula}", true, "${tableName}")); - `; - }); - - calcFunction += ` + + try { + ${usesUniversalFields ? 'await calculate_universal_fields(frm);' : ''} + + const { transformed, needsAsync, reportCalls } = transform_expression(${JSON.stringify(calc.formula)}, true, "${tableName}"); + + let finalExpression = transformed; + + if (reportCalls && reportCalls.length > 0) { + const reportResults = []; + + for (const call of reportCalls) { + const result = await getReportFieldValue( + frm, + call.reportName, + call.filters, + call.fieldName, + call.aggregation + ); + reportResults.push({ placeholder: call.placeholder, value: result }); + } + + for (const result of reportResults) { + finalExpression = finalExpression.replace(result.placeholder, result.value); + } + } + + let result = 0; + try { + result = eval(finalExpression); + } catch (e) { + console.error("Ошибка при вычислении формулы строки таблицы:", e, finalExpression); + } + + // Обновляем значение + row["${calc.field}"] = isNaN(result) ? 0 : result; frm.refresh_field("${tableName}"); - } catch (e) { + } catch (error) { + console.error("Ошибка в calculate_${tableName}_row:", error); } }`; - + + table_child_scripts[tableName].calculations.forEach(calc => { + calcFunction += ` + // Расчет для поля ${calc.field} + try { + const transformed = transform_expression(${JSON.stringify(calc.formula)}, true, "${tableName}").transformed; + row["${calc.field}"] = eval(transformed); + } catch (e) { + console.error("Ошибка при расчете поля ${calc.field} в строке таблицы ${tableName}:", e); + row["${calc.field}"] = 0; + } + `; + }); + + calcFunction += ` + frm.refresh_field("${tableName}"); + } catch (e) { + console.error("Ошибка при расчете строки таблицы ${tableName}:", e); + } + }`; + functions.push(calcFunction); // Добавляем обработчики для каждого триггерного поля @@ -1053,7 +1146,7 @@ function generateTransformExpressionFunction(universal_fields, report_fields) { ${Object.keys(universal_fields).map(fieldId => { const field = universal_fields[fieldId]; if (!field || !field.name) return ''; - return `transformed = transformed.replace(/univ\\\\(${field.name}\\\\)/g, "(frm.doc.__universalFields && frm.doc.__universalFields['${fieldId}'] || 0)");`; + return `transformed = transformed.replace(/univ\\(${field.name}\\)/g, "(frm.doc.__universalFields && frm.doc.__universalFields['${fieldId}'] || 0)");`; }).filter(Boolean).join('\n')} // === Обработка val() и row() === @@ -1103,6 +1196,7 @@ function generateTransformExpressionFunction(universal_fields, report_fields) { }`; } + function processFunctionField(field_info, fieldname, functions, table_child_scripts, table_doctypes, main_script, target_doctype) { @@ -1173,25 +1267,70 @@ function generateTableScripts(table_child_scripts, functions) { // Создаем общую функцию для расчета формул в строке таблицы let calcFunction = ` - function calculate_${table_name}_row(frm, cdt, cdn) { + async function calculate_${tableName}_row(frm, cdt, cdn) { let row = locals[cdt][cdn]; - - try {`; - - // Добавляем расчеты для каждого поля табличной части - table_child_scripts[table_name].calculations.forEach(calc => { - calcFunction += ` - // Расчет для поля ${calc.field} - row["${calc.field}"] = eval(${calc.formula}); - `; - }); - - calcFunction += ` - frm.refresh_field("${table_name}"); - } catch (e) { + + try { + ${usesUniversalFields ? 'await calculate_universal_fields(frm);' : ''} + + const { transformed, needsAsync, reportCalls } = transform_expression(${JSON.stringify(calc.formula)}, true, "${tableName}"); + + let finalExpression = transformed; + + if (reportCalls && reportCalls.length > 0) { + const reportResults = []; + + for (const call of reportCalls) { + const result = await getReportFieldValue( + frm, + call.reportName, + call.filters, + call.fieldName, + call.aggregation + ); + reportResults.push({ placeholder: call.placeholder, value: result }); + } + + for (const result of reportResults) { + finalExpression = finalExpression.replace(result.placeholder, result.value); + } + } + + let result = 0; + try { + result = eval(finalExpression); + } catch (e) { + console.error("Ошибка при вычислении формулы строки таблицы:", e, finalExpression); + } + + // Обновляем значение + row["${calc.field}"] = isNaN(result) ? 0 : result; + frm.refresh_field("${tableName}"); + } catch (error) { + console.error("Ошибка в calculate_${tableName}_row:", error); } }`; - + + table_child_scripts[tableName].calculations.forEach(calc => { + calcFunction += ` + // Расчет для поля ${calc.field} + try { + const transformed = transform_expression(${JSON.stringify(calc.formula)}, true, "${tableName}").transformed; + row["${calc.field}"] = eval(transformed); + } catch (e) { + console.error("Ошибка при расчете поля ${calc.field} в строке таблицы ${tableName}:", e); + row["${calc.field}"] = 0; + } + `; + }); + + calcFunction += ` + frm.refresh_field("${tableName}"); + } catch (e) { + console.error("Ошибка при расчете строки таблицы ${tableName}:", e); + } + }`; + functions.push(calcFunction); // Добавляем обработчики для каждого триггерного поля @@ -4962,34 +5101,36 @@ function transform_expression(expression, for_child_table = false, table_name = const reportField = Object.values(reportFields).find(f => f.name === reportName); if (reportField) { - let filters = {}; + // Безопасно обрабатываем JSON фильтров + let filtersJson = "{}"; try { - filters = JSON.parse(reportField.filters_json || "{}"); + const filtersObj = JSON.parse(reportField.filters_json || "{}"); - // Заменяем компанию на специальный маркер если нет - if (filters.company && typeof filters.company === 'string' && !filters.company.startsWith('$')) { - filters.company = "$DefaultCompany"; + // Заменяем значения компании и дат на маркеры + if (filtersObj.company && typeof filtersObj.company === 'string' && !filtersObj.company.startsWith('$')) { + filtersObj.company = "$DefaultCompany"; } - // Заменяем даты на специальный маркер если подходят для этого - if (filters.from_date && typeof filters.from_date === 'string') { - filters.from_date = "$AyIlSpecial"; + if (filtersObj.from_date && typeof filtersObj.from_date === 'string') { + filtersObj.from_date = "$AyIlSpecial"; } - if (filters.to_date && typeof filters.to_date === 'string') { - filters.to_date = "$AyIlSpecial"; + if (filtersObj.to_date && typeof filtersObj.to_date === 'string') { + filtersObj.to_date = "$AyIlSpecial"; } - if (filters.date && typeof filters.date === 'string') { - filters.date = "$AyIlSpecial"; + if (filtersObj.date && typeof filtersObj.date === 'string') { + filtersObj.date = "$AyIlSpecial"; } + + // Конвертируем объект в строку JSON и экранируем спецсимволы + filtersJson = JSON.stringify(filtersObj); } catch (e) { - console.error("Ошибка при обработке фильтров отчета:", e); - filters = {}; + filtersJson = "{}"; } - + reportCalls.push({ placeholder: placeholder, reportName: reportField.report, - filters: filters, + filters: JSON.parse(filtersJson), fieldName: reportField.field_name, aggregation: reportField.aggregation || 'sum' }); @@ -5001,16 +5142,41 @@ function transform_expression(expression, for_child_table = false, table_name = // === Обработка universal полей === const universalFields = JSON.parse(cur_frm.doc.universal_fields_storage || "{}"); + + // Создаем словарь соответствия имён полей и их идентификаторов + const fieldNameToId = {}; Object.keys(universalFields).forEach(fieldId => { const field = universalFields[fieldId]; - if (!field || !field.name) return; - - const pattern = new RegExp(`univ\\(${field.name}\\)`, 'g'); - transformed = transformed.replace(pattern, `(frm.doc.__universalFields && frm.doc.__universalFields["${fieldId}"] || 0)`); + if (field && field.name) { + fieldNameToId[field.name] = fieldId; + } }); + + // Ищем и заменяем все вызовы univ() в выражении + const univPattern = /univ\(([^)]+)\)/g; + while ((match = univPattern.exec(expression)) !== null) { + const fieldName = match[1]; // Имя поля внутри univ() + const fullMatch = match[0]; // Полное совпадение univ(Name) + + // Ищем ID поля по его имени + const fieldId = fieldNameToId[fieldName]; + + if (fieldId) { + // Заменяем вызов univ() на правильную ссылку с ID поля + transformed = transformed.replace( + fullMatch, + `(frm.doc.__universalFields && frm.doc.__universalFields["${fieldId}"] || 0)` + ); + } else { + // Если поле не найдено, заменяем на 0 и логируем ошибку + console.warn(`Universal field not found: ${fieldName}`); + transformed = transformed.replace(fullMatch, "0"); + } + } // === Обработка val() и row() === - const tablePrefixes = ["items", "taxes", "packed_items", "payments", "advances", "timesheets", "payment_schedule", "sales_team", "aksiz_test_table"]; + const tablePrefixes = ["items", "taxes", "packed_items", "payments", "advances", "timesheets", + "payment_schedule", "sales_team", "aksiz_test_table"]; for (const prefix of tablePrefixes) { const sumPattern = new RegExp(`sum\\(${prefix}_([^)]+)\\)`, 'g'); @@ -6602,198 +6768,137 @@ function openTableRowFieldSelector(frm, targetContainer, isForTrigger = false) { function generateTableRowFormulasHandlers(table_row_formulas, functions, target_doctype, universal_fields) { // Создаем функцию для инициализации формул строк табличных частей let initFunction = ` -// Функция для инициализации формул строк табличных частей -function initialize_table_row_formulas(frm) { - - // Получаем информацию о формулах для строк табличных частей - const formulas = ${JSON.stringify(table_row_formulas)}; - - // Группируем формулы по табличным частям - const formulasByTable = {}; - Object.keys(formulas).forEach(formulaId => { - const formula = formulas[formulaId]; - const tableName = formula.table_name; + // Функция для инициализации формул строк табличных частей + function initialize_table_row_formulas(frm) { - if (!formulasByTable[tableName]) { - formulasByTable[tableName] = []; - } + // Получаем информацию о формулах для строк табличных частей + const formulas = JSON.parse(frm.doc.table_row_formulas_storage || "{}"); - formulasByTable[tableName].push(formula); - }); - - // Устанавливаем обработчики для каждой табличной части - Object.keys(formulasByTable).forEach(tableName => { - if (!frm.doc[tableName]) { - return; - } - - // Проходим по строкам табличной части - frm.doc[tableName].forEach((row, rowIndex) => { - // Находим формулы для данной строки с триггером "При открытии документа" - const rowFormulas = formulasByTable[tableName].filter(formula => - parseInt(formula.row_index) === rowIndex + 1 && - formula.triggers && formula.triggers.split(',').map(t => t.trim()).includes("При открытии документа") - ); + // Группируем формулы по табличным частям + const formulasByTable = {}; + Object.keys(formulas).forEach(formulaId => { + const formula = formulas[formulaId]; + const tableName = formula.table_name; - // Вычисляем формулы для строки только если у них есть триггер "При открытии документа" - rowFormulas.forEach(formula => { - try { - - calculate_table_row_field(frm, tableName, rowIndex, formula.field_name, formula.formula); - } catch (error) { - } - }); + if (!formulasByTable[tableName]) { + formulasByTable[tableName] = []; + } + + formulasByTable[tableName].push(formula); }); - }); -}`; + + // Устанавливаем обработчики для каждой табличной части + Object.keys(formulasByTable).forEach(tableName => { + if (!frm.doc[tableName]) { + return; + } + + rowFormulas.forEach(async formula => { + try { + // Проверяем, нужен ли асинхронный вызов + const usesUniversal = formula.formula && formula.formula.includes("univ("); + const usesReports = formula.formula && formula.formula.includes("report("); + + // Всегда используем явный вызов calculate_table_row_field + await calculate_table_row_field(frm, tableName, rowIndex, formula.field_name, formula.formula, "refresh"); + } catch (error) { + } + }); + + // Вычисляем формулы для строки только если у них есть триггер "При открытии документа" + rowFormulas.forEach(formula => { + try { + // Проверяем, нужен ли асинхронный вызов + const usesUniversal = formula.formula && formula.formula.includes("univ("); + const usesReports = formula.formula && formula.formula.includes("report("); + + if (usesUniversal || usesReports) { + // Используем setTimeout для асинхронных вызовов, чтобы не блокировать процесс + setTimeout(async () => { + try { + await calculate_table_row_field(frm, tableName, rowIndex, formula.field_name, formula.formula, "refresh"); + } catch (error) { + console.error(\`Error calculating \${formula.field_name} in \${tableName} row \${rowIndex + 1}:\`, error); + } + }, 0); + } else { + // Синхронный вызов для обычных формул + calculate_table_row_field(frm, tableName, rowIndex, formula.field_name, formula.formula, "refresh"); + } + } catch (error) { + console.error(\`Error in initialize_table_row_formulas for \${formula.field_name}:\`, error); + } + }); + }); + }`; functions.push(initFunction); // Создаем функцию для расчета значения поля строки таблицы let calculateFunction = ` // Функция для расчета значения поля в строке табличной части -function calculate_table_row_field(frm, tableName, rowIndex, fieldName, formula, triggerField) { - +async function calculate_table_row_field(frm, tableName, rowIndex, fieldName, formula, triggerField) { try { - // Проверяем наличие таблицы и строки - if (!frm.doc[tableName]) { + if (!frm.doc[tableName] || !frm.doc[tableName][rowIndex]) { + console.warn("Table or row not found:", tableName, rowIndex); return; } - - if (!frm.doc[tableName][rowIndex]) { - return; - } - - // Получаем строку таблицы + const row = frm.doc[tableName][rowIndex]; - - // Получаем сетевое имя cdt и cdn для Frappe const cdt = frm.fields_dict[tableName].grid.doctype; const cdn = row.name; - - // Заменяем val() для полей документа - let modifiedFormula = formula.replace(/val\\(([^)]+)\\)/g, (match, fieldName) => { - return \`(parseFloat(frm.doc["\${fieldName}"]) || 0)\`; - }); - - // Заменяем row() для полей строки таблицы - modifiedFormula = modifiedFormula.replace(/row\\(([^)]+)\\)/g, (match, fieldName) => { - return \`(parseFloat(row["\${fieldName}"]) || 0)\`; - }); - - // Проверяем наличие универсальных полей в документе - if (frm.doc.__universalFields) { - - // Для прямого доступа к данным универсальных полей - const universalFieldValues = frm.doc.__universalFields; - - // Получаем хранилище универсальных полей для имен полей - let universalFieldDefs = {}; - try { - // Разбираем хранилище определений полей, если оно есть - if (frm.doc.universal_fields_storage && typeof frm.doc.universal_fields_storage === 'string') { - universalFieldDefs = JSON.parse(frm.doc.universal_fields_storage); - } else { - } - - // Создаем карту соответствия имен полей и их ID - const fieldNameToId = {}; - Object.keys(universalFieldDefs).forEach(fieldId => { - const field = universalFieldDefs[fieldId]; - if (field && field.name) { - fieldNameToId[field.name] = fieldId; - } - }); - - // Ищем univ() в формуле - const univPattern = /univ\\(([^)]+)\\)/g; - let match; - let resultFormula = modifiedFormula; - - // Для каждого найденного univ() в формуле - while ((match = univPattern.exec(formula)) !== null) { - const fieldName = match[1]; // Имя поля внутри univ() - const fullMatch = match[0]; // Полное совпадение univ(Name) - - // Сначала пытаемся найти поле по имени через карту - let fieldId = fieldNameToId[fieldName]; - let fieldValue; - - // Если нашли ID и есть значение - if (fieldId && universalFieldValues[fieldId] !== undefined) { - fieldValue = universalFieldValues[fieldId]; - } else { - // Проверяем, если имя поля используется напрямую как ID - if (universalFieldValues[fieldName] !== undefined) { - fieldValue = universalFieldValues[fieldName]; - } else { - // Если не нашли ни через карту, ни напрямую, попробуем поискать по всем полям - - // Поиск ID по имени поля без учета регистра - const lowerFieldName = fieldName.toLowerCase(); - for (const id in universalFieldDefs) { - const field = universalFieldDefs[id]; - if (field && field.name && field.name.toLowerCase() === lowerFieldName) { - fieldId = id; - fieldValue = universalFieldValues[id]; - break; - } - } - - // Если все еще не нашли, ищем первое доступное значение - if (fieldValue === undefined && Object.keys(universalFieldValues).length > 0) { - // Получаем первый доступный ID и его значение - const firstId = Object.keys(universalFieldValues)[0]; - fieldValue = universalFieldValues[firstId]; - } - } - - // Если все равно нет значения, используем 0 - if (fieldValue === undefined) { - fieldValue = 0; - } - } - - // Заменяем в формуле - resultFormula = resultFormula.replace(fullMatch, fieldValue); - } - - modifiedFormula = resultFormula; - } catch (error) { + + const { transformed, needsAsync, reportCalls } = transform_expression(formula, true, tableName); + + let finalExpression = transformed; + + // Загрузка универсальных полей при необходимости + if (formula.includes("univ(") && !frm.doc.__universalFields) { + await calculate_universal_fields(frm); + } + + // Обработка вызовов report() + if (reportCalls && reportCalls.length > 0) { + const reportResults = []; + + for (const call of reportCalls) { + const result = await getReportFieldValue( + frm, + call.reportName, + call.filters, + call.fieldName, + call.aggregation + ); + reportResults.push({ placeholder: call.placeholder, value: result }); + } + + for (const result of reportResults) { + finalExpression = finalExpression.replace(result.placeholder, result.value); } - } else { } - - // После всех замен проверяем, остались ли необработанные univ() - if (modifiedFormula.includes("univ(")) { - - // Используем простую замену по регулярному выражению - modifiedFormula = modifiedFormula.replace(/univ\\([^)]+\\)/g, "0"); + + // Вычисление выражения + let result = 0; + try { + result = eval(finalExpression); + } catch (e) { + console.error(\`Ошибка при вычислении выражения строки таблицы: \`, e, finalExpression); } - - // Выполняем формулу - let result = eval(modifiedFormula); - - // Убедимся, что результат - число, а не NaN + if (isNaN(result)) { result = 0; } - - // Обновляем значение поля в строке - row[fieldName] = result; - - // Обновляем отображение таблицы + + frappe.model.set_value(cdt, cdn, fieldName, result); + + // Обновляем таблицу frm.refresh_field(tableName); - - // Вызываем событие изменения поля для возможных цепочек зависимостей - if (frappe.ui.form.handlers[cdt] && frappe.ui.form.handlers[cdt][fieldName]) { - frappe.ui.form.handlers[cdt][fieldName].forEach(handler => { - handler.call(row, { frm: frm, cdt: cdt, cdn: cdn }); - }); - } - } catch (error) { + } catch (err) { + console.error(\`Error in calculate_table_row_field for \${fieldName}:\`, err); } -}`; +} +`; + functions.push(calculateFunction); @@ -6826,6 +6931,9 @@ function addTableRowFormulaHandlers(main_script, table_row_formulas, table_docty // Группируем формулы по таблицам и полям-триггерам const handlers = {}; + // Собираем все обработчики для триггеров + let triggerHandlers = {}; + // Специальное хранилище для триггеров в основном документе const mainDocumentTriggers = {}; @@ -6841,7 +6949,7 @@ function addTableRowFormulaHandlers(main_script, table_row_formulas, table_docty }; } -// Если есть триггеры, добавляем их + // Если есть триггеры, добавляем их if (formula.triggers) { const triggers = formula.triggers.split(',').map(t => t.trim()); @@ -6879,17 +6987,23 @@ function addTableRowFormulaHandlers(main_script, table_row_formulas, table_docty } }); - // Добавляем обработчики для полей основного документа + // Добавляем обработчики для полей основного документа в объект triggerHandlers Object.keys(mainDocumentTriggers).forEach(trigger => { - const hasUniversalField = mainDocumentTriggers[trigger].some(formula => formula.usesUniversal); + const formulas = mainDocumentTriggers[trigger]; + const hasUniversalField = formulas.some(formula => formula.usesUniversal); - main_script += ` - "${trigger}": ${hasUniversalField ? 'async ' : ''}function(frm) { + if (!triggerHandlers[trigger]) { + triggerHandlers[trigger] = { + isAsync: hasUniversalField, + handlers: [] + }; + } else if (hasUniversalField) { + triggerHandlers[trigger].isAsync = true; + } - ${hasUniversalField ? 'await calculate_universal_fields(frm);' : '// Универсальные поля не используются'} - - // Обрабатываем формулы для строк табличных частей - ${mainDocumentTriggers[trigger].map(formula => ` + // Для каждой формулы, добавляем код для вычисления + formulas.forEach(formula => { + const handlerCode = ` // Проверяем строку ${formula.rowIndex} в таблице ${formula.tableName} if (frm.doc.${formula.tableName} && frm.doc.${formula.tableName}.length >= ${formula.rowIndex}) { try { @@ -6900,10 +7014,22 @@ function addTableRowFormulaHandlers(main_script, table_row_formulas, table_docty calculate_table_row_field(frm, "${formula.tableName}", rowIndex, "${formula.fieldName}", formula, "${trigger}"); } catch (error) { } - } else { - }`).join('\n')} - }, -`; + }`; + + triggerHandlers[trigger].handlers.push(handlerCode); + }); + }); + + // Теперь создаем обработчики для каждого триггера + Object.keys(triggerHandlers).forEach(trigger => { + const handler = triggerHandlers[trigger]; + + main_script += ` + "${trigger}": ${handler.isAsync ? 'async ' : ''}function(frm) { + ${handler.isAsync ? 'await calculate_universal_fields(frm);' : '// Универсальные поля не используются'} + + ${handler.handlers.join('\n')} + },`; }); // Добавляем обработчики для событий add_row и remove_row для каждой таблицы @@ -7087,7 +7213,8 @@ function processFormulasForTriggersInClientScript(formula_data, table_row_formul row_index: formula.row_index, field_name: formula.field_name, formula: formula.formula, - uses_universal: formula.formula && formula.formula.includes("univ(") + uses_universal: formula.formula && formula.formula.includes("univ("), + uses_reports: formula.formula && formula.formula.includes("report(") }); } }); @@ -7150,11 +7277,14 @@ async function handle_predefined_events(frm) { // Обработка строк табличных частей с предопределенным событием ${refreshHandlers.filter(h => h.type === 'table_row').map(handler => { + // Проверяем, требуется ли асинхронный вызов + const needsAsync = handler.uses_universal || handler.uses_reports; return ` // Расчет поля ${handler.field_name} в строке ${handler.row_index} таблицы ${handler.table_name} try { - calculate_table_row_field(frm, "${handler.table_name}", ${parseInt(handler.row_index) - 1}, "${handler.field_name}", "${handler.formula}", ""); + ${needsAsync ? 'await ' : ''}calculate_table_row_field(frm, "${handler.table_name}", ${parseInt(handler.row_index) - 1}, "${handler.field_name}", "${handler.formula}", "refresh"); } catch (error) { + console.error("Error calculating table row formula for ${handler.field_name}:", error); }`; }).join('')} @@ -7178,39 +7308,17 @@ async function handle_predefined_events(frm) { } }).join('')} } catch (error) { + console.error("Error in handle_predefined_events:", error); } -} - -// Вспомогательная функция для обновления полей во всех строках таблицы -function update_table_fields(frm, tableName, fieldName) { - if (!frm.doc[tableName] || !frm.doc[tableName].length) return; - - // Получаем doctype таблицы - const cdt = frm.fields_dict[tableName].grid.doctype; - - // Вычисляем значение для каждой строки таблицы - frm.doc[tableName].forEach(row => { - const cdn = row.name; - - try { - // Вызываем обработчик для поля, если он есть - if (frappe.ui.form.handlers[cdt] && frappe.ui.form.handlers[cdt][fieldName]) { - frappe.ui.form.handlers[cdt][fieldName].forEach(handler => { - handler.call(row, { frm: frm, cdt: cdt, cdn: cdn }); - }); - } - } catch (error) { - } - }); - - // Обновляем отображение таблицы - frm.refresh_field(tableName); }`; return eventHandlerFunction; } function addTriggersToClientScript(main_script, formula_data, tableTriggersSet, refreshHandlers, universal_fields) { + // Создаем объект для хранения обработчиков по триггерам + const triggerHandlers = {}; + // Обрабатываем все формулы для основного документа Object.keys(formula_data).forEach(fieldname => { let field_info = formula_data[fieldname]; @@ -7246,11 +7354,24 @@ function addTriggersToClientScript(main_script, formula_data, tableTriggersSet, const hasUniversalTrigger = triggers.some(trigger => trigger.startsWith('univ(')); if (hasUniversalTrigger) { - // Создаем специальный обработчик для универсальных полей в триггерах - main_script += ` "universal_fields_updated": ${uses_universal ? 'async ' : ''}function(frm) { - // Пересчитываем поле ${fieldname}, если изменились универсальные поля - ${uses_universal ? `await calculate_${fieldname}(frm);` : `calculate_${fieldname}(frm);`} - },\n`; + // Создаем специальный обработчик для универсальных полей в триггерах, если его ещё нет + if (!triggerHandlers["universal_fields_updated"]) { + triggerHandlers["universal_fields_updated"] = { + needsAsync: uses_universal, + handlers: [] + }; + } + + // Обновляем флаг async если текущее поле требует асинхронности + if (uses_universal) { + triggerHandlers["universal_fields_updated"].needsAsync = true; + } + + // Добавляем обработчик в список + triggerHandlers["universal_fields_updated"].handlers.push({ + fieldname: fieldname, + uses_universal: uses_universal + }); // Удаляем универсальные поля из списка триггеров triggers = triggers.filter(trigger => !trigger.startsWith('univ(')); @@ -7275,44 +7396,107 @@ function addTriggersToClientScript(main_script, formula_data, tableTriggersSet, const rowIndex = parseInt(match[2]); const fieldInRow = match[3]; - // Добавляем специальный обработчик для табличной части - main_script += ` "${tableName}_on_form_rendered": function(frm) { - // Устанавливаем обработчик для поля ${fieldInRow} в строке ${rowIndex} таблицы ${tableName} - try { - if (frm.doc.${tableName} && frm.doc.${tableName}.length >= ${rowIndex}) { - const row = frm.doc.${tableName}[${rowIndex - 1}]; - const cdt = frm.fields_dict.${tableName}.grid.doctype; + // Создаем ключ для таблицы строк + const tableTriggerKey = `${tableName}_on_form_rendered`; + + // Инициализируем, если еще не существует + if (!triggerHandlers[tableTriggerKey]) { + triggerHandlers[tableTriggerKey] = { + needsAsync: false, + handlers: [] + }; + } + + // Обновляем флаг async + if (uses_universal) { + triggerHandlers[tableTriggerKey].needsAsync = true; + } + + // Добавляем обработчик для строки таблицы + triggerHandlers[tableTriggerKey].handlers.push({ + fieldname: fieldname, + uses_universal: uses_universal, + tableName: tableName, + rowIndex: rowIndex, + fieldInRow: fieldInRow + }); + } + } else { + // Для обычного поля, инициализируем обработчик триггера, если его еще нет + if (!triggerHandlers[trigger]) { + triggerHandlers[trigger] = { + needsAsync: uses_universal, + handlers: [] + }; + } + + // Обновляем флаг async + if (uses_universal) { + triggerHandlers[trigger].needsAsync = true; + } + + // Добавляем обработчик + triggerHandlers[trigger].handlers.push({ + fieldname: fieldname, + uses_universal: uses_universal + }); + } + } + }); + } + }); + + // Теперь генерируем JavaScript-код для всех триггеров + Object.keys(triggerHandlers).forEach(trigger => { + const handlerInfo = triggerHandlers[trigger]; + const isAsync = handlerInfo.needsAsync; + + if (trigger === "universal_fields_updated") { + // Специальный обработчик для универсальных полей + main_script += ` "universal_fields_updated": ${isAsync ? 'async ' : ''}function(frm) { + // Пересчитываем поля, которые зависят от универсальных полей +${handlerInfo.handlers.map(handler => { + return ` ${isAsync && handler.uses_universal ? 'await ' : ''}calculate_${handler.fieldname}(frm);`; +}).join('\n')} + },\n`; + } else if (trigger.endsWith('_on_form_rendered')) { + // Специальный обработчик для строк таблиц + const tableName = trigger.replace('_on_form_rendered', ''); + main_script += ` "${trigger}": function(frm) { + // Устанавливаем обработчики для полей в строках таблицы ${tableName} +${handlerInfo.handlers.map(handler => { + return ` try { + if (frm.doc.${handler.tableName} && frm.doc.${handler.tableName}.length >= ${handler.rowIndex}) { + const row = frm.doc.${handler.tableName}[${handler.rowIndex - 1}]; + const cdt = frm.fields_dict.${handler.tableName}.grid.doctype; const cdn = row.name; - // Устанавливаем обработчик для поля ${fieldInRow} + // Устанавливаем обработчик для поля ${handler.fieldInRow} if (!frm.__row_handlers) frm.__row_handlers = {}; - if (!frm.__row_handlers['${tableName}_${rowIndex}_${fieldInRow}']) { - frm.__row_handlers['${tableName}_${rowIndex}_${fieldInRow}'] = function(doc, cdt, cdn) { + if (!frm.__row_handlers['${handler.tableName}_${handler.rowIndex}_${handler.fieldInRow}']) { + frm.__row_handlers['${handler.tableName}_${handler.rowIndex}_${handler.fieldInRow}'] = function(doc, cdt, cdn) { if (doc.name === cdn) { - ${uses_universal ? 'setTimeout(async function() { await calculate_' + fieldname + '(frm); }, 0);' : 'calculate_' + fieldname + '(frm);'} + ${handler.uses_universal ? 'setTimeout(async function() { await calculate_' + handler.fieldname + '(frm); }, 0);' : 'calculate_' + handler.fieldname + '(frm);'} } }; // Регистрируем обработчик frappe.ui.form.on(cdt, { - "${fieldInRow}": frm.__row_handlers['${tableName}_${rowIndex}_${fieldInRow}'] + "${handler.fieldInRow}": frm.__row_handlers['${handler.tableName}_${handler.rowIndex}_${handler.fieldInRow}'] }); } } } catch (error) { - } + }`; +}).join('\n')} + },\n`; + } else { + // Обычный обработчик события + main_script += ` "${trigger}": ${isAsync ? 'async ' : ''}function(frm) { +${handlerInfo.handlers.map(handler => { + return ` ${isAsync && handler.uses_universal ? 'await ' : ''}calculate_${handler.fieldname}(frm);`; +}).join('\n')} },\n`; - } - } else { - // Добавляем обработчик для обычного поля - if (uses_universal) { - main_script += ` "${trigger}": async function(frm) { await calculate_${fieldname}(frm); },\n`; - } else { - main_script += ` "${trigger}": function(frm) { calculate_${fieldname}(frm); },\n`; - } - } - } - }); } }); @@ -7655,13 +7839,13 @@ function createReportFieldModal() { $('body').append(modalHtml); } -function openReportFieldModal(frm, fieldId = null) { +function openReportFieldModal(frm, fieldId = null, fieldCopy = null) { // Создаем/обновляем модальное окно createReportFieldModal(); - // Получаем значения для редактирования (если указан fieldId) - let fieldValues = {}; - if (fieldId) { + // Получаем значения для редактирования + let fieldValues = fieldCopy || {}; + if (!fieldCopy && fieldId) { const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); fieldValues = reportFields[fieldId] || {}; } @@ -7721,6 +7905,11 @@ function openReportFieldModal(frm, fieldId = null) { // Инициализируем обработчики для фильтров initFilterControls(d, current_filters); + + // Восстанавливаем специальные значения дат, если они есть + if (d.has_special_dates) { + restoreSpecialDateValues(d, current_filters); + } } // Загружаем поля отчета @@ -7774,6 +7963,13 @@ function openReportFieldModal(frm, fieldId = null) { options: 'JSON', default: fieldValues.filters_json || '{}' }, + { + fieldname: 'has_special_dates', + fieldtype: 'Check', + label: 'Использует специальные даты', + hidden: 1, + default: fieldValues.__has_special_dates || 0 + }, { label: 'Поле отчета', fieldname: 'field_section', @@ -7802,7 +7998,48 @@ function openReportFieldModal(frm, fieldId = null) { const values = d.get_values(); // Получаем JSON фильтров - values.filters_json = d.get_value('filters_json'); + let filters_json = d.get_value('filters_json'); + + // Восстанавливаем специальные значения перед сохранением + if (d.get_value('has_special_dates')) { + try { + let filters = JSON.parse(filters_json); + + // Восстанавливаем оригинальные значения дат + Object.keys(filters).forEach(key => { + if (key.startsWith('__original_')) { + const originalKey = key.replace('__original_', ''); + filters[originalKey] = filters[key]; // Восстанавливаем оригинальное значение + delete filters[key]; // Удаляем временный ключ + } + }); + + // Устанавливаем значения выбранные в выпадающих списках для дат + if (d.filter_controls) { + Object.keys(d.filter_controls).forEach(fieldname => { + const ctrl = d.filter_controls[fieldname]; + if (ctrl && ctrl.df && + (fieldname.toLowerCase().includes('date') || + fieldname.toLowerCase().includes('from') || + fieldname.toLowerCase().includes('to'))) { + + const $select = $(`#filter_${fieldname} select.filter-value`); + if ($select.length) { + const value = $select.val(); + // Если выбрано специальное значение или ссылка на поле документа + if (value === "$AyIlSpecial" || (value && value.startsWith('doc.'))) { + filters[fieldname] = value; + } + } + } + }); + } + + filters_json = JSON.stringify(filters); + } catch (e) { + console.error("Ошибка при восстановлении специальных значений:", e); + } + } // Проверяем, что все обязательные поля заполнены if (!values.name || !values.report || !values.field_name) { @@ -7814,6 +8051,9 @@ function openReportFieldModal(frm, fieldId = null) { return; } + // Обновляем значение фильтров + values.filters_json = filters_json; + // Сохраняем данные saveReportField(frm, values, fieldId); @@ -7821,6 +8061,9 @@ function openReportFieldModal(frm, fieldId = null) { } }); + // Сохраняем информацию о специальных датах + d.has_special_dates = fieldValues.__has_special_dates; + // Показываем диалог d.show(); @@ -7832,6 +8075,42 @@ function openReportFieldModal(frm, fieldId = null) { } } +// Функция для восстановления специальных значений дат в UI +function restoreSpecialDateValues(d, current_filters) { + try { + if (!d.has_special_dates) return; + + // Ищем оригинальные значения дат, сохраненные с префиксом __original_ + Object.keys(current_filters).forEach(key => { + if (key.startsWith('__original_')) { + const originalKey = key.replace('__original_', ''); + const originalValue = current_filters[key]; + + // Если есть контрол для этого поля + if (d.filter_controls && d.filter_controls[originalKey]) { + const $select = $(`#filter_${originalKey} select.filter-value`); + if ($select.length) { + // Проверяем, есть ли опция с таким значением + if ($select.find(`option[value="${originalValue}"]`).length) { + $select.val(originalValue); + } else if (originalValue === "$AyIlSpecial") { + // Если это специальное значение месяца, ищем соответствующую опцию + $select.find('option').each(function() { + if ($(this).text().toLowerCase().includes('месяц')) { + $select.val($(this).val()); + return false; // Останавливаем цикл + } + }); + } + } + } + } + }); + } catch (e) { + console.error("Ошибка при восстановлении специальных значений дат:", e); + } +} + function generateFiltersHtml(filters, currentValues) { let html = '
'; @@ -7878,7 +8157,7 @@ function initFilterControls(d, currentValues) { // Обновляем фильтры updateFiltersJson(d); return; - } + } const fieldId = `filter_${filter.fieldname}`; // Используем jQuery для поиска элемента @@ -7896,6 +8175,11 @@ function initFilterControls(d, currentValues) { currentValues[filter.fieldname] : filter.default; + // Проверяем, есть ли оригинальное значение для даты + const originalKey = `__original_${filter.fieldname}`; + const hasOriginalValue = currentValues[originalKey] !== undefined; + const originalValue = hasOriginalValue ? currentValues[originalKey] : null; + // Создаем контрол в зависимости от типа поля let control; @@ -7951,23 +8235,24 @@ function initFilterControls(d, currentValues) { const hasIlField = targetDateFields.some(field => field.fieldname === 'il'); if (targetDateFields.length > 0) { - // Создаем селект с опциями для полей даты + // Создаем селект с опциями из полей с датой let selectHtml = ''); + + // Добавляем обработчик для нового поля даты + $container.find('.filter-value').on('change', function() { + updateFiltersJson(d); + }); } - else if (selectedValue === '$AyIlSpecial') { - // Для специального значения $AyIlSpecial + else if (selectedValue === "$AyIlSpecial") { + // Помечаем в data-атрибуте, что это специальное поле ay + $(this).attr('data-is-special-ay-field', 'true'); + d.set_value('has_special_dates', 1); + updateFiltersJson(d); + } + else { updateFiltersJson(d); } }); @@ -7995,7 +8290,19 @@ function initFilterControls(d, currentValues) { return $container.find('.filter-value').val() || ""; }, set_value: function(v) { - $container.find('.filter-value').val(v); + const $select = $container.find('.filter-value'); + + // Если в селекте есть опция с таким значением, выбираем ее + if ($select.find(`option[value="${v}"]`).length > 0) { + $select.val(v); + } + // Иначе, если это обычная дата, переключаемся на пользовательский ввод + else if (v && v !== "$AyIlSpecial" && !v.startsWith("doc.")) { + $select.val('custom').trigger('change'); + setTimeout(() => { + $container.find('.filter-value').val(v); + }, 100); + } }, df: { fieldname: filter.fieldname, @@ -8006,11 +8313,16 @@ function initFilterControls(d, currentValues) { // Сохраняем контрол d.filter_controls[filter.fieldname] = control; - // Если есть текущее значение, пытаемся его установить - if (currentValue) { - try { - control.set_value(currentValue); - } catch(e) {} + // Если имеется оригинальное значение (например, $AyIlSpecial) + if (hasOriginalValue) { + // Сразу устанавливаем значение специального маркера + $container.find('.filter-value').val(originalValue).trigger('change'); + d.set_value('has_special_dates', 1); + } else { + // Иначе используем обычное значение + if (typeof currentValue === 'string' && currentValue.startsWith('doc.')) { + $container.find('.filter-value').val(currentValue).trigger('change'); + } } // Добавляем событие изменения для обновления JSON @@ -8018,38 +8330,62 @@ function initFilterControls(d, currentValues) { updateFiltersJson(d); }); } else { - // Если нет полей даты, создаем обычное поле даты createDefaultDateControl(); } } else { - // Если не удалось получить метаданные, создаем обычное поле даты createDefaultDateControl(); } }); } else { - // Если target_doctype не указан, создаем обычное поле даты createDefaultDateControl(); } // Функция для создания стандартного контрола даты function createDefaultDateControl() { - control = new frappe.ui.form.ControlDate({ - parent: $container, - df: { - fieldname: filter.fieldname, - label: filter.label || filter.fieldname, - fieldtype: 'Date', - reqd: filter.reqd || 0, - default: currentValue, - change: function() { - updateFiltersJson(d); + try { + control = new frappe.ui.form.ControlDate({ + parent: $container, + df: { + fieldname: filter.fieldname, + label: filter.label || filter.fieldname, + fieldtype: 'Date', + reqd: filter.reqd || 0, + default: currentValue + }, + render_input: true + }); + + // Сохраняем контрол + d.filter_controls[filter.fieldname] = control; + } catch (e) { + console.error("Ошибка при создании контрола даты:", e); + $container.html(''); + + if (currentValue && !hasOriginalValue) { + $container.find('.filter-value').val(currentValue); + } + + control = { + get_value: function() { + return $container.find('.filter-value').val(); + }, + set_value: function(v) { + $container.find('.filter-value').val(v); + }, + df: { + fieldname: filter.fieldname, + fieldtype: 'Date' } - }, - render_input: true - }); + }; + + // Сохраняем контрол + d.filter_controls[filter.fieldname] = control; + } - // Сохраняем контрол - d.filter_controls[filter.fieldname] = control; + // Обработчик изменения для обновления JSON + $container.find('input').on('change', function() { + updateFiltersJson(d); + }); } break; @@ -8106,39 +8442,8 @@ function initFilterControls(d, currentValues) { } }); - // Функция для обновления JSON фильтров - function updateFiltersJson(d) { - try { - // Собираем все значения фильтров - const values = {}; - Object.keys(d.filter_controls || {}).forEach(fieldname => { - const ctrl = d.filter_controls[fieldname]; - if (ctrl) { - try { - const value = ctrl.get_value(); - if (value !== undefined && value !== null && value !== '') { - values[fieldname] = value; - } - } catch (e) { - frappe.show_alert({ - message: `Ошибка при получении значения фильтра ${fieldname}: ${e.message}`, - indicator: 'red' - }); - } - } - }); - - // Обновляем JSON фильтров - if (Object.keys(values).length > 0) { - d.set_value('filters_json', JSON.stringify(values, null, 2)); - } - } catch (e) { - frappe.show_alert({ - message: `Ошибка при обновлении фильтров: ${e.message}`, - indicator: 'red' - }); - } - } + // Обновляем JSON фильтров + updateFiltersJson(d); } function loadReportFields(d, reportName) { @@ -8203,13 +8508,16 @@ function loadReportFields(d, reportName) { // Проверяем, какие обязательные фильтры отсутствуют const missingRequiredFilters = requiredFilters.filter(f => { + // Пропускаем скрытые поля, которые заполняются автоматически + if (f.hidden) return false; + const isMissing = !(f.fieldname in filters) || filters[f.fieldname] === undefined || filters[f.fieldname] === null || filters[f.fieldname] === ""; return isMissing; - }); - + }); + // Если есть отсутствующие обязательные фильтры даты, пробуем заполнить их значениями по умолчанию const missingDateFilters = missingRequiredFilters.filter(f => f.fieldtype === 'Date' && ( @@ -8335,6 +8643,7 @@ function loadReportFields(d, reportName) { } } +// Функция сохранения поля отчета function saveReportField(frm, values, fieldId = null) { // Используем имя поля в качестве идентификатора или указанный ID const newFieldId = fieldId || frappe.utils.get_random(10); @@ -8356,29 +8665,23 @@ function saveReportField(frm, values, fieldId = null) { return; } - // Обработка JSON фильтров для замены прямых значений компании на специальный маркер - if (values.filters_json) { - try { - let filters = JSON.parse(values.filters_json); - - // Заменяем конкретную компанию на специальный маркер - if (filters.company) { - filters.company = "$DefaultCompany"; + // Обработка JSON фильтров + let filters = {}; + try { + // Парсим JSON фильтров + filters = JSON.parse(values.filters_json || "{}"); + + // Удаляем служебные поля + Object.keys(filters).forEach(key => { + if (key.startsWith('__')) { + delete filters[key]; } - - // Проверяем даты и заменяем на маркеры для Ay/Il где нужно - for (const key in filters) { - if (typeof filters[key] === 'string' && filters[key].startsWith('doc.ay')) { - // Заменяем на специальный маркер для дат с месяцем/годом - filters[key] = "$AyIlSpecial"; - } - } - - // Обновляем JSON фильтров - values.filters_json = JSON.stringify(filters); - } catch (e) { - console.error("Ошибка при обработке JSON фильтров:", e); - } + }); + + // Обновляем JSON фильтров + values.filters_json = JSON.stringify(filters); + } catch (e) { + console.error("Ошибка при обработке JSON фильтров:", e); } // Сохраняем информацию о поле отчета @@ -8401,7 +8704,7 @@ function saveReportField(frm, values, fieldId = null) { } catch (error) { frappe.msgprint({ title: __('Ошибка'), - message: __('Не удалось сохранить поле отчета.'), + message: __('Не удалось сохранить поле отчета: ') + error.message, indicator: 'red' }); } @@ -8423,13 +8726,13 @@ function renderReportFields(frm) { const field = reportFields[fieldId]; const $fieldButton = $(` -
+
-
+ @@ -8467,7 +8770,61 @@ function renderReportFields(frm) { } function editReportField(frm, fieldId) { - openReportFieldModal(frm, fieldId); + try { + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + const field = reportFields[fieldId]; + + if (!field) { + frappe.throw("Поле отчета не найдено"); + return; + } + + // Важно: создаем копию поля, чтобы не изменять оригинал в хранилище + const fieldCopy = {...field}; + + // Обрабатываем фильтры перед открытием диалога + if (fieldCopy.filters_json) { + try { + let filters = JSON.parse(fieldCopy.filters_json); + const processedFilters = {}; + + // Проходим по всем фильтрам и обрабатываем специальные значения + Object.keys(filters).forEach(key => { + let value = filters[key]; + + // Обработка дат + if (key.toLowerCase().includes('date') && value === "$AyIlSpecial") { + // Заменяем специальное значение на обычную дату для временного использования + // Потом мы восстановим специальное значение при сохранении + processedFilters["__original_" + key] = "$AyIlSpecial"; // Сохраняем оригинальное значение + processedFilters[key] = frappe.datetime.get_today(); // Временная валидная дата + } else if (typeof value === 'string' && value.startsWith("doc.") && + (key.toLowerCase().includes('date') || key.toLowerCase().includes('from') || key.toLowerCase().includes('to'))) { + // Обрабатываем также ссылки на поля документа + processedFilters["__original_" + key] = value; // Сохраняем оригинальное значение + processedFilters[key] = frappe.datetime.get_today(); // Временная валидная дата + } else { + processedFilters[key] = value; + } + }); + + // Обновляем JSON фильтров в копии + fieldCopy.filters_json = JSON.stringify(processedFilters); + fieldCopy.__has_special_dates = true; // Маркер для последующего восстановления + } catch (e) { + console.error("Ошибка при обработке JSON фильтров:", e); + } + } + + // Открываем модальное окно с обработанной копией поля + openReportFieldModal(frm, fieldId, fieldCopy); + } catch (error) { + frappe.msgprint({ + title: __('Ошибка'), + message: __('Не удалось открыть поле отчета для редактирования: ') + error.message, + indicator: 'red' + }); + } } function deleteReportField(frm, fieldId) { @@ -8496,3 +8853,136 @@ function deleteReportField(frm, fieldId) { } ); } + +function processTableRowFormulas(table_row_formulas, table_doctypes) { + // Хранилище для объединенных обработчиков + const triggerHandlers = {}; + + // Группируем формулы по триггерам + Object.values(table_row_formulas).forEach(formula => { + const tableName = formula.table_name; + const rowIndex = parseInt(formula.row_index); + const usesUniversal = formula.formula && formula.formula.includes("univ("); + const usesReports = formula.formula && formula.formula.includes("report("); + + // Если есть триггеры, добавляем их + if (formula.triggers) { + const triggers = formula.triggers.split(',').map(t => t.trim()); + + triggers.forEach(trigger => { + // Пропускаем триггер "При открытии документа", он обрабатывается отдельно + if (!trigger || trigger === "При открытии документа" || + trigger === "when_document_opens" || trigger === "document_opens") { + return; + } + + // Инициализируем обработчик триггера, если его еще нет + if (!triggerHandlers[trigger]) { + triggerHandlers[trigger] = { + isAsync: usesUniversal || usesReports, + handlers: [] + }; + } else if (usesUniversal || usesReports) { + // Если текущая формула требует async, помечаем весь обработчик как async + triggerHandlers[trigger].isAsync = true; + } + + // Код для вычисления формулы + const handlerCode = ` + // Проверяем строку ${rowIndex} в таблице ${tableName} + if (frm.doc.${tableName} && frm.doc.${tableName}.length >= ${rowIndex}) { + try { + // Индекс строки на 1 меньше номера строки + const rowIndex = ${rowIndex - 1}; + const formula = "${formula.formula}"; + + ${usesUniversal || usesReports ? 'await ' : ''}calculate_table_row_field(frm, "${tableName}", rowIndex, "${formula.field_name}", formula, "${trigger}"); + } catch (error) { + console.error("Error calculating table row formula for ${formula.field_name}:", error); + } + }`; + + triggerHandlers[trigger].handlers.push(handlerCode); + }); + } + }); + + // Добавляем обработчики для событий add_row и remove_row для каждой таблицы + const tables = new Set(); + Object.values(table_row_formulas).forEach(formula => { + tables.add(formula.table_name); + }); + + tables.forEach(tableName => { + // Add handler + triggerHandlers[`${tableName}_add`] = { + isAsync: false, + handlers: [ + `// Обновляем индексы строк после добавления новой строки`, + `setTimeout(() => initialize_table_row_formulas(frm), 100);` + ] + }; + + // Remove handler + triggerHandlers[`${tableName}_remove`] = { + isAsync: false, + handlers: [ + `// Обновляем индексы строк после удаления строки`, + `setTimeout(() => initialize_table_row_formulas(frm), 100);` + ] + }; + }); + + return triggerHandlers; +} + +// Функция для обновления JSON фильтров +function updateFiltersJson(d) { + try { + // Собираем все значения фильтров + const values = {}; + Object.keys(d.filter_controls || {}).forEach(fieldname => { + const ctrl = d.filter_controls[fieldname]; + if (ctrl) { + try { + const value = ctrl.get_value(); + if (value !== undefined && value !== null && value !== '') { + // Сохраняем все значения как есть + values[fieldname] = value; + + // Проверка на специальные значения дат + if ((value === "$AyIlSpecial" || (typeof value === 'string' && value.startsWith("doc."))) && + (fieldname.toLowerCase().includes('date') || + fieldname.toLowerCase().includes('from') || + fieldname.toLowerCase().includes('to'))) { + + // Сохраняем оригинальное значение отдельно + values[`__original_${fieldname}`] = value; + + // Временно заменяем значение на валидную дату для проверок + values[fieldname] = frappe.datetime.get_today(); + + // Отмечаем, что у нас есть специальные даты + d.set_value('has_special_dates', 1); + } + } + } catch (e) { + frappe.show_alert({ + message: `Ошибка при получении значения фильтра ${fieldname}: ${e.message}`, + indicator: 'red' + }); + } + } + }); + + // Обновляем JSON фильтров + if (Object.keys(values).length > 0) { + d.set_value('filters_json', JSON.stringify(values, null, 2)); + } + } catch (e) { + frappe.show_alert({ + message: `Ошибка при обновлении фильтров: ${e.message}`, + indicator: 'red' + }); + } +} \ No newline at end of file diff --git a/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js index b1ca8ac..ea81f1b 100644 --- a/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js +++ b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js @@ -34,7 +34,7 @@ frappe.ui.form.on('Report Simulator', { // Функция для загрузки фильтров отчета function load_report_filters(frm) { frappe.call({ - method: 'formula_editor.formula_editor.doctype.report_simulator.report_simulator.get_report_filters_dynamic', + method: 'formula_editor_js.formula_editor_js.doctype.report_simulator.report_simulator.get_report_filters_dynamic', args: { report_name: frm.doc.report }, diff --git a/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js b/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js index d55a772..19dcb4a 100644 --- a/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js +++ b/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js @@ -44,7 +44,7 @@ frappe.listview_settings['Report Simulator'] = { // Выполняем отчет frappe.call({ - method: 'formula_editor.formula_editor.doctype.report_simulator.report_simulator.run_report_simulation', + method: 'formula_editor_js.formula_editor_js.doctype.report_simulator.report_simulator.run_report_simulation', args: { report_name: values.report, filters_json: values.filters_json diff --git a/formula_editor/hooks.py b/formula_editor/hooks.py index 2e500b6..ac01635 100644 --- a/formula_editor/hooks.py +++ b/formula_editor/hooks.py @@ -1,32 +1,27 @@ app_name = "formula_editor" app_title = "Formula Editor" -app_publisher = "Jey ERP" -app_description = "App for adding formulas for any doctype" -app_email = "info@jey_erp.az" -app_license = "unlicense" - -# Apps -# ------------------ - -# required_apps = [] - -# Each item in the list will be shown as an app in the apps page -# add_to_apps_screen = [ -# { -# "name": "formula_editor", -# "logo": "/assets/formula_editor/logo.png", -# "title": "Formula Editor", -# "route": "/formula_editor", -# "has_permission": "formula_editor.api.permission.has_app_permission" -# } -# ] +app_publisher = "Your Company" +app_description = "Advanced formula editing capabilities for Frappe/ERPNext" +app_email = "your.email@example.com" +app_license = "MIT" # Includes in # ------------------ # include js, css files in header of desk.html -# app_include_css = "/assets/formula_editor/css/formula_editor.css" -# app_include_js = "/assets/formula_editor/js/formula_editor.js" +app_include_css = [ + "/assets/formula_editor/css/formula_editor.css" +] +app_include_js = [ + "/assets/formula_editor/js/core.js", + "/assets/formula_editor/js/index.js", + "/assets/formula_editor/js/report_fields.js", + "/assets/formula_editor/js/table_row_formulas.js", + "/assets/formula_editor/js/triggers.js", + "/assets/formula_editor/js/ui.js", + "/assets/formula_editor/js/universal_fields.js", + "/assets/formula_editor/js/utils.js" +] # include js, css files in header of web template # web_include_css = "/assets/formula_editor/css/formula_editor.css" @@ -43,15 +38,12 @@ app_license = "unlicense" # page_js = {"page" : "public/js/file.js"} # include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.js"} -# doctype_list_js = {"doctype" : "public/js/doctype_list.js"} -# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} -# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} +doctype_js = { + "DocType": "public/js/doctype_formula_editor.js", +} -# Svg Icons -# ------------------ -# include app icons in desk -# app_include_icons = "formula_editor/public/icons.svg" +# include js in doctype list views +# doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # Home Pages # ---------- @@ -61,7 +53,7 @@ app_license = "unlicense" # website user home page (by Role) # role_home_page = { -# "Role": "home_page" +# "Role": "home_page" # } # Generators @@ -70,16 +62,13 @@ app_license = "unlicense" # automatically create page for each record of this doctype # website_generators = ["Web Page"] -# automatically load and sync documents of this doctype from downstream apps -# importable_doctypes = [doctype_1] - # Jinja # ---------- # add methods and filters to jinja environment # jinja = { -# "methods": "formula_editor.utils.jinja_methods", -# "filters": "formula_editor.utils.jinja_filters" +# "methods": "formula_editor.utils.jinja_methods", +# "filters": "formula_editor.utils.jinja_filters" # } # Installation @@ -94,22 +83,6 @@ app_license = "unlicense" # before_uninstall = "formula_editor.uninstall.before_uninstall" # after_uninstall = "formula_editor.uninstall.after_uninstall" -# Integration Setup -# ------------------ -# To set up dependencies/integrations with other apps -# Name of the app being installed is passed as an argument - -# before_app_install = "formula_editor.utils.before_app_install" -# after_app_install = "formula_editor.utils.after_app_install" - -# Integration Cleanup -# ------------------- -# To clean up dependencies/integrations with other apps -# Name of the app being uninstalled is passed as an argument - -# before_app_uninstall = "formula_editor.utils.before_app_uninstall" -# after_app_uninstall = "formula_editor.utils.after_app_uninstall" - # Desk Notifications # ------------------ # See frappe.core.notifications.get_notification_config @@ -121,11 +94,11 @@ app_license = "unlicense" # Permissions evaluated in scripted ways # permission_query_conditions = { -# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", +# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", # } # # has_permission = { -# "Event": "frappe.desk.doctype.event.event.has_permission", +# "Event": "frappe.desk.doctype.event.event.has_permission", # } # DocType Class @@ -133,7 +106,7 @@ app_license = "unlicense" # Override standard doctype classes # override_doctype_class = { -# "ToDo": "custom_app.overrides.CustomToDo" +# "ToDo": "custom_app.overrides.CustomToDo" # } # Document Events @@ -150,21 +123,21 @@ doc_events = { # --------------- # scheduler_events = { -# "all": [ -# "formula_editor.tasks.all" -# ], -# "daily": [ -# "formula_editor.tasks.daily" -# ], -# "hourly": [ -# "formula_editor.tasks.hourly" -# ], -# "weekly": [ -# "formula_editor.tasks.weekly" -# ], -# "monthly": [ -# "formula_editor.tasks.monthly" -# ], +# "all": [ +# "formula_editor.tasks.all" +# ], +# "daily": [ +# "formula_editor.tasks.daily" +# ], +# "hourly": [ +# "formula_editor.tasks.hourly" +# ], +# "weekly": [ +# "formula_editor.tasks.weekly" +# ], +# "monthly": [ +# "formula_editor.tasks.monthly" +# ], # } # Testing @@ -176,14 +149,14 @@ doc_events = { # ------------------------------ # # override_whitelisted_methods = { -# "frappe.desk.doctype.event.event.get_events": "formula_editor.event.get_events" +# "frappe.desk.doctype.event.event.get_events": "formula_editor.event.get_events" # } # # each overriding function accepts a `data` argument; # generated from the base implementation of the doctype dashboard, # along with any modifications made in other Frappe apps # override_doctype_dashboards = { -# "Task": "formula_editor.task.get_dashboard_data" +# "Task": "formula_editor.task.get_dashboard_data" # } # exempt linked doctypes from being automatically cancelled @@ -209,37 +182,37 @@ doc_events = { # -------------------- # user_data_fields = [ -# { -# "doctype": "{doctype_1}", -# "filter_by": "{filter_by}", -# "redact_fields": ["{field_1}", "{field_2}"], -# "partial": 1, -# }, -# { -# "doctype": "{doctype_2}", -# "filter_by": "{filter_by}", -# "partial": 1, -# }, -# { -# "doctype": "{doctype_3}", -# "strict": False, -# }, -# { -# "doctype": "{doctype_4}" -# } +# { +# "doctype": "{doctype_1}", +# "filter_by": "{filter_by}", +# "redact_fields": ["{field_1}", "{field_2}"], +# "partial": 1, +# }, +# { +# "doctype": "{doctype_2}", +# "filter_by": "{filter_by}", +# "partial": 1, +# }, +# { +# "doctype": "{doctype_3}", +# "strict": False, +# }, +# { +# "doctype": "{doctype_4}" +# } # ] # Authentication and authorization # -------------------------------- # auth_hooks = [ -# "formula_editor.auth.validate" +# "formula_editor.auth.validate" # ] -# Automatically update python controller files with type annotations for this app. -# export_python_type_annotations = True - -# default_log_clearing_doctypes = { -# "Logging DocType Name": 30 # days to retain logs -# } +# Translation +# -------------------------------- +# Make link fields search translated document names for these doctypes +# Recommended only for DocTypes which have limited documents with untranslated names +# For example: Role, Gender, etc. +# translated_search_doctypes = [] \ No newline at end of file diff --git a/formula_editor/public/css/formula_editor.css b/formula_editor/public/css/formula_editor.css new file mode 100644 index 0000000..e3093a0 --- /dev/null +++ b/formula_editor/public/css/formula_editor.css @@ -0,0 +1,359 @@ +/** + * Formula Editor Styles + */ + + .formula-editor-container { + position: relative; + border: 1px solid var(--border-color); + border-radius: 4px; + min-height: 100px; + display: flex; + flex-direction: column; + } + + .formula-editor-container.has-error { + border-color: var(--red-500); + } + + .formula-editor-wrapper { + display: flex; + flex-direction: column; + height: 100%; + } + + .formula-editor-top { + border-bottom: 1px solid var(--border-color); + padding: 4px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .formula-editor-main { + display: flex; + flex: 1; + min-height: 200px; + } + + .formula-editor-section { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .formula-editor-code-section { + flex: 2; + border-right: 1px solid var(--border-color); + } + + .formula-editor-panels-section { + flex: 1; + min-width: 250px; + max-width: 300px; + } + + .formula-editor-bottom { + border-top: 1px solid var(--border-color); + padding: 4px 8px; + display: flex; + align-items: center; + } + + .formula-editor-toolbar { + display: flex; + align-items: center; + gap: 4px; + } + + .formula-editor-code-container { + flex: 1; + position: relative; + overflow: hidden; + } + + .formula-editor-code { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + font-family: monospace; + font-size: 13px; + height: 100%; + width: 100%; + } + + .formula-editor-textarea { + width: 100%; + height: 100%; + resize: none; + border: none; + padding: 8px; + font-family: monospace; + font-size: 13px; + } + + .formula-editor-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + + .panel-header { + padding: 8px; + font-weight: bold; + border-bottom: 1px solid var(--border-color); + } + + .panel-search { + padding: 4px 8px; + border-bottom: 1px solid var(--border-color); + } + + .panel-content { + padding: 0; + overflow-y: auto; + flex: 1; + } + + .field-group { + margin-bottom: 2px; + } + + .group-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + cursor: pointer; + background-color: var(--bg-light-gray); + } + + .group-header:hover { + background-color: var(--bg-gray); + } + + .group-name { + font-weight: 500; + } + + .group-count { + font-size: 0.8em; + color: var(--text-muted); + margin-right: 4px; + } + + .group-content { + padding: 4px 0; + } + + .collapsed .group-content { + display: none; + } + + .field-item { + display: flex; + align-items: center; + padding: 4px 8px 4px 16px; + cursor: pointer; + border-left: 2px solid transparent; + } + + .field-item:hover { + background-color: var(--bg-light-gray); + border-left-color: var(--primary); + } + + .field-icon { + margin-right: 8px; + color: var(--text-muted); + width: 16px; + text-align: center; + } + + .field-label { + flex: 1; + margin-right: 4px; + } + + .field-type { + font-size: 0.8em; + color: var(--text-muted); + } + + .category-item { + padding: 6px 12px; + cursor: pointer; + border-left: 2px solid transparent; + } + + .category-item:hover, + .category-item.selected { + background-color: var(--bg-light-gray); + } + + .category-item.selected { + border-left-color: var(--primary); + font-weight: 500; + } + + .function-item { + padding: 8px; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + } + + .function-item:hover { + background-color: var(--bg-light-gray); + } + + .function-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 4px; + } + + .function-name { + font-weight: 500; + margin-right: 8px; + } + + .function-syntax { + font-family: monospace; + font-size: 0.9em; + color: var(--text-muted); + } + + .function-description { + font-size: 0.9em; + color: var(--text-muted); + } + + .formula-editor-error-panel { + color: var(--red-500); + display: flex; + align-items: center; + width: 100%; + } + + .formula-editor-error-panel i { + margin-right: 8px; + } + + .formula-editor-error-panel.hidden { + display: none; + } + + .formula-editor-btn { + margin-left: 4px; + } + + .formula-list-container { + padding: 8px; + } + + .formula-list-item { + border: 1px solid var(--border-color); + border-radius: 4px; + margin-bottom: 8px; + padding: 8px; + } + + .formula-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + } + + .formula-name { + font-weight: 500; + margin-right: 8px; + } + + .formula-field { + font-size: 0.9em; + color: var(--text-muted); + } + + .formula-actions { + display: flex; + gap: 4px; + } + + .formula-code { + padding: 8px; + background-color: var(--bg-light-gray); + border-radius: 4px; + font-family: monospace; + font-size: 0.9em; + margin-bottom: 8px; + white-space: pre-wrap; + } + + .formula-description { + font-size: 0.9em; + color: var(--text-muted); + } + + .formula-toggle-btn.disabled { + opacity: 0.5; + } + + .formula-calculated-field { + background-color: rgba(var(--primary-rgb), 0.1); + position: relative; + } + + .formula-calculated-field::after { + content: "ƒ"; + position: absolute; + top: 0; + right: 4px; + font-size: 12px; + color: var(--primary); + font-weight: bold; + } + + .empty-state { + padding: 16px; + text-align: center; + color: var(--text-muted); + } + + .formula-editor-container.read-only .formula-editor-code, + .formula-editor-container.read-only .formula-editor-textarea { + background-color: var(--bg-light-gray); + cursor: not-allowed; + } + + .formula-editor-report-btn { + margin-left: 8px; + } + + .category-badge { + background-color: var(--bg-light-gray); + font-size: 0.8em; + padding: 2px 4px; + border-radius: 4px; + margin-left: auto; + } + + /* For trigger mode */ + .formula-editor-container.trigger-mode .formula-editor-panels-section { + flex: 1.5; + max-width: 400px; + } + + /* For report mode */ + .formula-editor-container.report-mode .formula-editor-code-section { + flex: 1.5; + } + + /* Hidden elements */ + .hidden { + display: none !important; + } \ No newline at end of file diff --git a/formula_editor/public/js/core.js b/formula_editor/public/js/core.js new file mode 100644 index 0000000..244992a --- /dev/null +++ b/formula_editor/public/js/core.js @@ -0,0 +1,466 @@ +/** + * Formula Editor Core + * Contains the main FormulaEditor class and core functionality + */ + +import { + safeEval, + validateFormula, + extractReferencedFields, + generateUniqueId, + debounce, + formatErrorMessage, + showToast, + isEmpty, + deepClone, + safeParseJSON +} from './utils'; + +class FormulaEditor { + /** + * Create a new FormulaEditor instance + * @param {Object} options - Configuration options + * @param {string} options.parent - Parent element selector or element + * @param {string} options.doctype - DocType name + * @param {string} [options.fieldname] - Field name + * @param {Object} [options.doc] - Current document + * @param {Function} [options.on_change] - Change callback + * @param {Array} [options.custom_fields] - Additional custom fields + * @param {boolean} [options.is_report] - Whether this is used in a report + * @param {boolean} [options.is_trigger] - Whether this is used for triggers + */ + constructor(options) { + this.options = Object.assign({ + parent: null, + doctype: null, + fieldname: null, + doc: null, + on_change: null, + custom_fields: [], + is_report: false, + is_trigger: false + }, options); + + // Validate required options + if (!this.options.parent) { + throw new Error(__('Parent element is required for Formula Editor')); + } + + if (!this.options.doctype) { + throw new Error(__('DocType is required for Formula Editor')); + } + + // Initialize properties + this.initialized = false; + this.formula = ''; + this.fields = []; + this.errors = []; + this.meta = null; + this.parent = typeof this.options.parent === 'string' + ? document.querySelector(this.options.parent) + : this.options.parent; + this.editor_id = generateUniqueId('formula-editor'); + + // Create debounced functions + this.debouncedValidate = debounce(this.validate.bind(this), 500); + this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); + + // Internal data storage + this._cached_fields = null; + this._field_options = null; + this._referenced_fields = []; + + // Initialize the editor + this.init(); + } + + /** + * Initialize the editor + * @private + */ + async init() { + try { + if (this.initialized) return; + + // Fetch metadata for the DocType + await this.fetchMeta(); + + // Get available fields + await this.getFields(); + + // Create the UI elements + this.createElements(); + + // Bind events + this.bindEvents(); + + this.initialized = true; + + // Initialize with current value if available + if (this.options.doc && this.options.fieldname) { + this.setValue(this.options.doc[this.options.fieldname] || ''); + } + + } catch (error) { + console.error('Error initializing Formula Editor:', error); + this.showError(__('Failed to initialize Formula Editor')); + } + } + + /** + * Fetch DocType metadata + * @private + */ + async fetchMeta() { + try { + this.meta = frappe.get_meta(this.options.doctype); + + if (!this.meta) { + // If meta is not in cache, fetch it + this.meta = await frappe.model.with_doctype(this.options.doctype); + } + } catch (error) { + console.error('Error fetching metadata:', error); + throw new Error(__('Could not fetch metadata for {0}', [this.options.doctype])); + } + } + + /** + * Get available fields for the formula + * @private + */ + async getFields() { + // Return cached fields if available + if (this._cached_fields) { + this.fields = deepClone(this._cached_fields); + return; + } + + try { + const fields = []; + + // Add standard fields + this.addStandardFields(fields); + + // Add fields from the DocType + this.addDocTypeFields(fields); + + // Add custom fields if provided + if (Array.isArray(this.options.custom_fields)) { + fields.push(...this.options.custom_fields); + } + + // Save and cache the fields + this.fields = fields; + this._cached_fields = deepClone(fields); + + } catch (error) { + console.error('Error getting fields:', error); + throw new Error(__('Could not get fields for Formula Editor')); + } + } + + /** + * Add standard fields like 'doc', etc. + * @param {Array} fields - Fields array to add to + * @private + */ + addStandardFields(fields) { + // Add 'doc' as a standard field + fields.push({ + fieldname: 'doc', + label: __('Current Document'), + type: 'Object', + standard: true + }); + + // Add other standard fields based on context + if (this.options.is_report) { + fields.push({ + fieldname: 'row', + label: __('Current Row'), + type: 'Object', + standard: true + }); + } + } + + /** + * Add fields from the DocType + * @param {Array} fields - Fields array to add to + * @private + */ + addDocTypeFields(fields) { + if (!this.meta || !this.meta.fields) return; + + // Add all fields from the DocType + for (const field of this.meta.fields) { + // Skip certain field types + if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { + continue; + } + + fields.push({ + fieldname: field.fieldname, + label: field.label || field.fieldname, + type: field.fieldtype, + options: field.options, + table_fields: this.getTableFields(field), + is_child_table: field.fieldtype === 'Table' + }); + } + } + + /** + * Get fields for a Table type field + * @param {Object} field - The table field + * @returns {Array} Child table fields + * @private + */ + getTableFields(field) { + if (field.fieldtype !== 'Table' || !field.options) return []; + + const childFields = []; + const childMeta = frappe.get_meta(field.options); + + if (!childMeta || !childMeta.fields) return []; + + for (const childField of childMeta.fields) { + // Skip certain field types + if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(childField.fieldtype)) { + continue; + } + + childFields.push({ + fieldname: childField.fieldname, + label: childField.label || childField.fieldname, + type: childField.fieldtype, + options: childField.options + }); + } + + return childFields; + } + + /** + * Create UI elements for the editor + * @private + */ + createElements() { + // This is a placeholder - UI creation will be moved to ui.js + // Here we just create the basic container + this.container = document.createElement('div'); + this.container.id = this.editor_id; + this.container.className = 'formula-editor-container'; + + // Create a simple textarea for now (will be replaced by proper UI) + this.textarea = document.createElement('textarea'); + this.textarea.className = 'formula-editor-input'; + this.textarea.placeholder = __('Enter your formula here...'); + + // Error display element + this.errorEl = document.createElement('div'); + this.errorEl.className = 'formula-editor-error hidden'; + + // Append to container + this.container.appendChild(this.textarea); + this.container.appendChild(this.errorEl); + + // Append to parent + this.parent.appendChild(this.container); + } + + /** + * Bind events to the editor elements + * @private + */ + bindEvents() { + if (!this.textarea) return; + + // Input event for formula changes + this.textarea.addEventListener('input', () => { + this.formula = this.textarea.value; + this.debouncedValidate(); + this.debouncedOnChange(); + }); + + // Focus/blur events + this.textarea.addEventListener('focus', this.handleFocus.bind(this)); + this.textarea.addEventListener('blur', this.handleBlur.bind(this)); + } + + /** + * Handle focus event + * @private + */ + handleFocus() { + this.container.classList.add('focused'); + } + + /** + * Handle blur event + * @private + */ + handleBlur() { + this.container.classList.remove('focused'); + this.validate(); + } + + /** + * Handle formula change + * @private + */ + handleChange() { + if (typeof this.options.on_change === 'function') { + this.options.on_change(this.formula, this._referenced_fields); + } + } + + /** + * Set the formula value + * @param {string} value - Formula to set + * @public + */ + setValue(value) { + this.formula = value || ''; + + if (this.textarea) { + this.textarea.value = this.formula; + } + + this.validate(); + } + + /** + * Get the current formula value + * @returns {string} Current formula + * @public + */ + getValue() { + return this.formula; + } + + /** + * Validate the current formula + * @returns {boolean} Whether the formula is valid + * @public + */ + validate() { + // Clear previous errors + this.errors = []; + + // Skip validation for empty formula + if (!this.formula.trim()) { + this.hideError(); + return true; + } + + // Validate formula syntax + const validation = validateFormula(this.formula); + + if (!validation.isValid) { + this.errors.push(validation.error); + this.showError(validation.error); + return false; + } + + // Get referenced fields + this._referenced_fields = extractReferencedFields( + this.formula, + this.fields.map(f => f.fieldname) + ); + + // Hide error if validation passed + this.hideError(); + return true; + } + + /** + * Show error message + * @param {string} message - Error message + * @private + */ + showError(message) { + if (!this.errorEl) return; + + this.errorEl.textContent = formatErrorMessage(message); + this.errorEl.classList.remove('hidden'); + this.container.classList.add('has-error'); + } + + /** + * Hide error message + * @private + */ + hideError() { + if (!this.errorEl) return; + + this.errorEl.textContent = ''; + this.errorEl.classList.add('hidden'); + this.container.classList.remove('has-error'); + } + + /** + * Evaluate the current formula + * @param {Object} [context] - Additional context for evaluation + * @returns {*} Result of the evaluation + * @public + */ + evaluate(context = {}) { + if (!this.formula.trim()) return null; + + // Validate before evaluation + if (!this.validate()) { + return null; + } + + // Create evaluation context + const evalContext = { + doc: this.options.doc || {}, + ...context + }; + + try { + return safeEval(this.formula, evalContext); + } catch (error) { + console.error('Error evaluating formula:', error); + this.showError(error.message); + return null; + } + } + + /** + * Gets the fields referenced in the current formula + * @returns {Array} Array of referenced field names + * @public + */ + getReferencedFields() { + return [...this._referenced_fields]; + } + + /** + * Destroy the editor and clean up + * @public + */ + destroy() { + // Remove event listeners + if (this.textarea) { + this.textarea.removeEventListener('input', this.debouncedOnChange); + this.textarea.removeEventListener('focus', this.handleFocus); + this.textarea.removeEventListener('blur', this.handleBlur); + } + + // Remove DOM elements + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + + // Clean up properties + this.initialized = false; + this._cached_fields = null; + this._field_options = null; + this._referenced_fields = []; + } +} + +export default FormulaEditor; \ No newline at end of file diff --git a/formula_editor/public/js/index.js b/formula_editor/public/js/index.js new file mode 100644 index 0000000..d5bded0 --- /dev/null +++ b/formula_editor/public/js/index.js @@ -0,0 +1,376 @@ +/** + * Formula Editor + * Main entry point for the Formula Editor module + */ + +import FormulaEditor from './core'; +import FormulaEditorUI from './ui'; +import FormulaEditorTriggers from './triggers'; +import FormulaEditorUniversalFields from './universal_fields'; +import FormulaEditorTableRows from './table_row_formulas'; +import FormulaEditorReportFields from './report_fields'; +import * as utils from './utils'; + +// Create the top-level namespace for the formula editor +frappe.formula_editor = { + // Core classes + FormulaEditor, + FormulaEditorUI, + FormulaEditorTriggers, + FormulaEditorUniversalFields, + FormulaEditorTableRows, + FormulaEditorReportFields, + + // Utilities + utils, + + // Instances cache + _instances: {}, + + /** + * Create a new formula editor instance + * @param {Object} options - Configuration options + * @returns {FormulaEditor} Formula editor instance + */ + create(options) { + const editor = new FormulaEditor(options); + const id = options.id || `formula_editor_${Math.random().toString(36).substr(2, 9)}`; + + this._instances[id] = editor; + return editor; + }, + + /** + * Get a formula editor instance by ID + * @param {string} id - Instance ID + * @returns {FormulaEditor|null} Formula editor instance or null if not found + */ + get(id) { + return this._instances[id] || null; + }, + + /** + * Destroy a formula editor instance + * @param {string} id - Instance ID + * @returns {boolean} Success status + */ + destroy(id) { + const editor = this.get(id); + if (!editor) return false; + + editor.destroy(); + delete this._instances[id]; + return true; + }, + + /** + * Initialize formula editor for a form field + * @param {frappe.ui.form.Field} field - Form field to initialize editor for + * @param {Object} [options={}] - Additional options + * @returns {FormulaEditor} Formula editor instance + */ + initForField(field, options = {}) { + if (!field || !field.frm || !field.df) { + console.error('Invalid field provided to initForField'); + return null; + } + + const fieldId = `${field.frm.doctype}_${field.df.fieldname}`; + + // Check if editor already exists for this field + const existingEditor = this.get(fieldId); + if (existingEditor) return existingEditor; + + // Create wrapper element + const $wrapper = $('
'); + field.$wrapper.find('.control-input-wrapper').append($wrapper); + + // Create editor + const editorOptions = { + id: fieldId, + parent: $wrapper[0], + doctype: field.frm.doctype, + fieldname: field.df.fieldname, + doc: field.frm.doc, + on_change: (value, referencedFields) => { + field.set_input(value); + + // Store referenced fields to update formula when they change + if (Array.isArray(referencedFields)) { + field._formulaReferencedFields = referencedFields; + } + }, + ...options + }; + + const editor = this.create(editorOptions); + + // Set up field change listener to keep formula in sync + field._originalRefresh = field.refresh; + field.refresh = () => { + const result = field._originalRefresh(); + + // Update editor value if it doesn't match field value + if (editor.getValue() !== field.value) { + editor.setValue(field.value); + } + + return result; + }; + + // Set up listener for referenced fields + this._setupReferencedFieldsListeners(field.frm, field); + + return editor; + }, + + /** + * Set up listeners for fields referenced in a formula + * @param {frappe.ui.form.Form} frm - Form instance + * @param {frappe.ui.form.Field} formulaField - Formula field + * @private + */ + _setupReferencedFieldsListeners(frm, formulaField) { + if (!frm || !formulaField || !formulaField._formulaReferencedFields) return; + + const referencedFields = formulaField._formulaReferencedFields; + + referencedFields.forEach(fieldname => { + const field = frm.fields_dict[fieldname]; + if (!field) return; + + // Skip if already set up + if (field._formulaListenerSetup) return; + + // Set up change listener + const originalOnChange = field.df.change; + field.df.change = () => { + if (typeof originalOnChange === 'function') { + originalOnChange(); + } + + // Update all formula fields that reference this field + Object.values(frm.fields_dict).forEach(f => { + if (f._formulaReferencedFields && + f._formulaReferencedFields.includes(fieldname) && + f !== formulaField) { + + // Get the editor + const editorId = `${frm.doctype}_${f.df.fieldname}`; + const editor = frappe.formula_editor.get(editorId); + + if (editor) { + // Re-evaluate the formula + const result = editor.evaluate(); + if (result !== undefined) { + f.set_input(result); + } + } + } + }); + }; + + field._formulaListenerSetup = true; + }); + }, + + /** + * Initialize formula editor for a report + * @param {frappe.views.ReportView|frappe.query_reports.QueryReport} report - Report instance + * @param {string} reportName - Report name + * @param {string} [reportType='report'] - Report type + * @returns {FormulaEditorReportFields} Report fields handler instance + */ + initForReport(report, reportName, reportType = 'report') { + if (!report || !reportName) { + console.error('Invalid report provided to initForReport'); + return null; + } + + const reportId = `${reportType}_${reportName}`; + + // Check if already initialized + if (report._formulaEditor) return report._formulaEditor; + + // Create a minimal editor instance for the report + const editorOptions = { + id: reportId, + doctype: reportName, + is_report: true + }; + + const editor = new FormulaEditor(editorOptions); + + // Create report fields handler + const reportFields = new FormulaEditorReportFields({ + editor: editor, + reportFormulas: [] + }); + + // Integrate with the report + reportFields.integrateWithReport(report, reportName, reportType); + + // Store reference on the report + report._formulaEditor = reportFields; + + return reportFields; + }, + + /** + * Initialize table row formulas for a form + * @param {frappe.ui.form.Form} frm - Form instance + * @param {Object} [options={}] - Additional options + * @returns {FormulaEditorTableRows} Table rows handler instance + */ + initTableFormulas(frm, options = {}) { + if (!frm || !frm.doctype) { + console.error('Invalid form provided to initTableFormulas'); + return null; + } + + const formId = `table_formulas_${frm.doctype}`; + + // Check if already initialized + if (frm._tableFormulasEditor) return frm._tableFormulasEditor; + + // Create a minimal editor instance for the form + const editorOptions = { + id: formId, + doctype: frm.doctype, + doc: frm.doc + }; + + const editor = new FormulaEditor(editorOptions); + + // Create table rows handler + const tableRows = new FormulaEditorTableRows({ + editor: editor, + tableFormulas: options.tableFormulas || [], + onChange: options.onChange + }); + + // Attach to the form + tableRows.attachToForm(frm); + + // Store reference on the form + frm._tableFormulasEditor = tableRows; + + return tableRows; + }, + + /** + * Initialize triggers for a form + * @param {frappe.ui.form.Form} frm - Form instance + * @param {Object} [options={}] - Additional options + * @returns {FormulaEditorTriggers} Triggers handler instance + */ + initTriggers(frm, options = {}) { + if (!frm || !frm.doctype) { + console.error('Invalid form provided to initTriggers'); + return null; + } + + const formId = `triggers_${frm.doctype}`; + + // Check if already initialized + if (frm._triggersEditor) return frm._triggersEditor; + + // Create a minimal editor instance for the form + const editorOptions = { + id: formId, + doctype: frm.doctype, + doc: frm.doc, + is_trigger: true + }; + + const editor = new FormulaEditor(editorOptions); + + // Create triggers handler + const triggers = new FormulaEditorTriggers({ + editor: editor, + triggers: options.triggers || [], + onChange: options.onChange + }); + + // Attach to the form + triggers.attachToForm(frm); + + // Store reference on the form + frm._triggersEditor = triggers; + + return triggers; + }, + + /** + * Make a field into a formula editor + * @param {frappe.ui.form.Form} frm - Form instance + * @param {string} fieldname - Field to convert + * @param {Object} [options={}] - Additional options + */ + makeFieldFormula(frm, fieldname, options = {}) { + if (!frm || !fieldname) return; + + const field = frm.get_field(fieldname); + if (!field) return; + + // Add formula button to the field + if (!field.$wrapper.find('.formula-editor-btn').length) { + const $btn = $(` + + `); + + field.$wrapper.find('.control-input-wrapper').append($btn); + + // Show formula editor on click + $btn.on('click', () => { + this.showFieldFormulaDialog(frm, fieldname, options); + }); + } + }, + + /** + * Show formula editor dialog for a field + * @param {frappe.ui.form.Form} frm - Form instance + * @param {string} fieldname - Field name + * @param {Object} [options={}] - Additional options + */ + showFieldFormulaDialog(frm, fieldname, options = {}) { + const field = frm.get_field(fieldname); + if (!field) return; + + // Create dialog + const dialog = new frappe.ui.Dialog({ + title: __('Formula Editor: {0}', [field.df.label || fieldname]), + fields: [ + { + fieldtype: 'Code', + fieldname: 'formula', + label: __('Formula'), + options: 'JavaScript', + default: field.value || '' + } + ], + primary_action_label: __('Apply'), + primary_action: (values) => { + field.set_input(values.formula); + dialog.hide(); + } + }); + + dialog.show(); + } +}; + +// Register setup hooks +$(document).on('form-load', function(event, frm) { + // Find formula fields and convert them to formula editors + frm.meta.fields.forEach(df => { + if (df.fieldtype === 'Code' && df.options === 'Formula') { + frappe.formula_editor.makeFieldFormula(frm, df.fieldname); + } + }); +}); + +export default frappe.formula_editor; \ No newline at end of file diff --git a/formula_editor/public/js/report_fields.js b/formula_editor/public/js/report_fields.js new file mode 100644 index 0000000..53882e2 --- /dev/null +++ b/formula_editor/public/js/report_fields.js @@ -0,0 +1,1161 @@ +/** + * Formula Editor Report Fields + * Handles formula functionality for reports and analytical views + */ + +import { + safeEval, + validateFormula, + formatErrorMessage, + debounce, + isEmpty, + deepClone, + showToast +} from './utils'; + +class FormulaEditorReportFields { + /** + * Create a new FormulaEditorReportFields instance + * @param {Object} options - Configuration options + * @param {Object} options.editor - Reference to the main FormulaEditor instance + * @param {Array} [options.reportFormulas=[]] - Initial report formulas + * @param {Function} [options.onChange] - Callback for formula changes + */ + constructor(options) { + this.options = Object.assign({ + editor: null, + reportFormulas: [], + onChange: null + }, options); + + // Validate required options + if (!this.options.editor) { + throw new Error(__('Editor instance is required for FormulaEditorReportFields')); + } + + // Initialize properties + this.editor = this.options.editor; + this.reportFormulas = deepClone(this.options.reportFormulas || []); + this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); + + // Initialize + this.init(); + } + + /** + * Initialize report formulas + * @private + */ + init() { + // Ensure all report formulas have proper structure + this.validateReportFormulas(); + } + + /** + * Validate report formulas structure + * @private + */ + validateReportFormulas() { + if (!Array.isArray(this.reportFormulas)) { + this.reportFormulas = []; + return; + } + + // Process each report formula to ensure it has all required properties + this.reportFormulas = this.reportFormulas.map(formula => { + // Skip if not an object + if (typeof formula !== 'object' || formula === null) { + return null; + } + + // Ensure required properties + return { + id: formula.id || `report_formula_${Date.now()}`, + name: formula.name || __('Unnamed Report Formula'), + formula: formula.formula || '', + reportType: formula.reportType || 'report', // 'report', 'query-report', 'custom' + reportName: formula.reportName || '', + fieldName: formula.fieldName || '', + columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'), + position: formula.position || 'end', // 'start', 'end', after:fieldname + positionField: formula.positionField || null, + is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true, + format: formula.format || null, // 'Currency', 'Percent', etc. + enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, + description: formula.description || '', + precision: formula.precision !== undefined ? formula.precision : 2, + aggregation: formula.aggregation || null // 'sum', 'avg', 'min', 'max', 'count' + }; + }).filter(Boolean); // Remove invalid formulas + } + + /** + * Get all report formulas + * @returns {Array} List of report formulas + * @public + */ + getReportFormulas() { + return deepClone(this.reportFormulas); + } + + /** + * Get report formulas for a specific report + * @param {string} reportName - Report name + * @param {string} [reportType='report'] - Report type ('report', 'query-report', 'custom') + * @returns {Array} List of formulas for the report + * @public + */ + getFormulasForReport(reportName, reportType = 'report') { + return this.reportFormulas.filter(f => + f.reportName === reportName && f.reportType === reportType + ).map(f => deepClone(f)); + } + + /** + * Get a report formula by ID + * @param {string} id - Formula ID + * @returns {Object|null} Formula object or null if not found + * @public + */ + getReportFormula(id) { + const formula = this.reportFormulas.find(f => f.id === id); + return formula ? deepClone(formula) : null; + } + + /** + * Add a new report formula + * @param {Object} formula - Formula data + * @returns {string} New formula ID + * @public + */ + addReportFormula(formula = {}) { + if (!formula.reportName) { + throw new Error(__('Report name is required')); + } + + const id = formula.id || `report_formula_${Date.now()}`; + + const newFormula = { + id: id, + name: formula.name || __('Unnamed Report Formula'), + formula: formula.formula || '', + reportType: formula.reportType || 'report', // 'report', 'query-report', 'custom' + reportName: formula.reportName, + fieldName: formula.fieldName || `calculated_${id.substring(id.length - 6)}`, + columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'), + position: formula.position || 'end', // 'start', 'end', after:fieldname + positionField: formula.positionField || null, + is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true, + format: formula.format || null, // 'Currency', 'Percent', etc. + enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, + description: formula.description || '', + precision: formula.precision !== undefined ? formula.precision : 2, + aggregation: formula.aggregation || null // 'sum', 'avg', 'min', 'max', 'count' + }; + + this.reportFormulas.push(newFormula); + this.debouncedOnChange(); + + return newFormula.id; + } + + /** + * Update a report formula + * @param {string} id - Formula ID + * @param {Object} updates - Properties to update + * @returns {boolean} Success status + * @public + */ + updateReportFormula(id, updates) { + const index = this.reportFormulas.findIndex(f => f.id === id); + if (index === -1) return false; + + // Clone the formula + const formula = deepClone(this.reportFormulas[index]); + + // Apply updates + Object.keys(updates).forEach(key => { + if (key !== 'id') { // Don't allow changing the ID + formula[key] = updates[key]; + } + }); + + // Update the formula + this.reportFormulas[index] = formula; + this.debouncedOnChange(); + + return true; + } + + /** + * Delete a report formula + * @param {string} id - Formula ID + * @returns {boolean} Success status + * @public + */ + deleteReportFormula(id) { + const index = this.reportFormulas.findIndex(f => f.id === id); + if (index === -1) return false; + + this.reportFormulas.splice(index, 1); + this.debouncedOnChange(); + + return true; + } + + /** + * Enable/disable a report formula + * @param {string} id - Formula ID + * @param {boolean} enabled - Whether to enable the formula + * @returns {boolean} Success status + * @public + */ + setReportFormulaEnabled(id, enabled) { + return this.updateReportFormula(id, { enabled: !!enabled }); + } + + /** + * Validate a report formula + * @param {string} id - Formula ID, or formula string if testOnly is true + * @param {boolean} [testOnly=false] - Whether to validate a formula string instead of a saved formula + * @returns {Object} Validation result with isValid and error + * @public + */ + validateReportFormula(id, testOnly = false) { + let formula; + + if (testOnly) { + formula = id; // In this case, id is actually the formula string + } else { + const formulaObj = this.getReportFormula(id); + if (!formulaObj) { + return { isValid: false, error: __('Formula not found') }; + } + formula = formulaObj.formula; + } + + if (!formula || typeof formula !== 'string' || !formula.trim()) { + return { isValid: true, error: null }; + } + + // Use basic formula validation + return validateFormula(formula); + } + + /** + * Apply formulas to report data + * @param {Array} data - Report data rows + * @param {Array} columns - Report columns + * @param {string} reportName - Report name + * @param {string} [reportType='report'] - Report type ('report', 'query-report', 'custom') + * @returns {Object} Modified data and columns + * @public + */ + applyFormulasToReport(data, columns, reportName, reportType = 'report') { + try { + if (!Array.isArray(data) || !Array.isArray(columns) || !reportName) { + return { data, columns }; + } + + // Get formulas for this report + const formulas = this.getFormulasForReport(reportName, reportType) + .filter(f => f.enabled); + + if (formulas.length === 0) { + return { data, columns }; + } + + // Create a column map for easy lookup + const columnMap = {}; + columns.forEach((col, index) => { + columnMap[col.fieldname] = index; + }); + + // Process each formula + const newData = deepClone(data); + const newColumns = deepClone(columns); + + formulas.forEach(formula => { + try { + // Check if column already exists + const existingColumnIndex = columnMap[formula.fieldName]; + + // Create new column if needed + if (existingColumnIndex === undefined) { + const newColumn = { + fieldname: formula.fieldName, + label: formula.columnLabel, + fieldtype: formula.is_numeric ? 'Float' : 'Data', + options: formula.format, + precision: formula.precision, + width: 120, + is_formula: true, + formula_reference: formula.id + }; + + // Determine position of the new column + if (formula.position === 'start') { + newColumns.unshift(newColumn); + + // Update column map + Object.keys(columnMap).forEach(key => { + columnMap[key]++; + }); + columnMap[formula.fieldName] = 0; + } else if (formula.position === 'end' || !formula.positionField) { + newColumns.push(newColumn); + columnMap[formula.fieldName] = newColumns.length - 1; + } else { + // Position after specific field + const posIndex = columnMap[formula.positionField]; + if (posIndex !== undefined) { + newColumns.splice(posIndex + 1, 0, newColumn); + + // Update column map + Object.keys(columnMap).forEach(key => { + if (columnMap[key] > posIndex) { + columnMap[key]++; + } + }); + columnMap[formula.fieldName] = posIndex + 1; + } else { + // Fallback to end + newColumns.push(newColumn); + columnMap[formula.fieldName] = newColumns.length - 1; + } + } + } + + // Apply formula to each row + newData.forEach((row, rowIndex) => { + try { + const context = { + row: row, + data: newData, + rowIndex: rowIndex, + columns: newColumns, + columnMap: columnMap, + reportName: reportName, + reportType: reportType + }; + + const result = this.evaluateReportFormula(formula.formula, context); + + // Find where to put the result + const targetIdx = columnMap[formula.fieldName]; + + if (targetIdx !== undefined) { + // If row is an array, extend it if needed + if (Array.isArray(row)) { + if (row.length <= targetIdx) { + while (row.length < targetIdx) { + row.push(null); + } + row.push(result); + } else { + row[targetIdx] = result; + } + } + // If row is an object, add/update the property + else if (typeof row === 'object' && row !== null) { + row[formula.fieldName] = result; + } + } + } catch (rowError) { + console.error(`Error applying formula to row ${rowIndex}:`, rowError); + // Set error value + const targetIdx = columnMap[formula.fieldName]; + if (targetIdx !== undefined) { + if (Array.isArray(row)) { + row[targetIdx] = '#ERROR'; + } else if (typeof row === 'object' && row !== null) { + row[formula.fieldName] = '#ERROR'; + } + } + } + }); + + // Apply aggregation if specified + if (formula.aggregation && formula.is_numeric) { + this.applyAggregation(newData, formula); + } + + } catch (formulaError) { + console.error(`Error processing formula ${formula.id}:`, formulaError); + } + }); + + return { data: newData, columns: newColumns }; + + } catch (error) { + console.error('Error applying formulas to report:', error); + showToast(__('Error applying formulas to report: {0}', [formatErrorMessage(error)]), 'error'); + return { data, columns }; + } + } + + /** + * Evaluate a formula in the context of a report row + * @param {string} formula - Formula to evaluate + * @param {Object} context - Evaluation context + * @returns {*} Evaluation result + * @private + */ + evaluateReportFormula(formula, context) { + return safeEval(formula, context); + } + + /** + * Apply aggregation to a formula field + * @param {Array} data - Report data + * @param {Object} formula - Formula configuration + * @private + */ + applyAggregation(data, formula) { + if (!Array.isArray(data) || data.length === 0) return; + + const fieldName = formula.fieldName; + let result; + + switch (formula.aggregation) { + case 'sum': + result = this.calculateSum(data, fieldName); + break; + case 'avg': + result = this.calculateAverage(data, fieldName); + break; + case 'min': + result = this.calculateMin(data, fieldName); + break; + case 'max': + result = this.calculateMax(data, fieldName); + break; + case 'count': + result = this.calculateCount(data, fieldName); + break; + default: + return; + } + + // Look for total row (last row or one with is_total=true) + let totalRow = null; + + for (let i = data.length - 1; i >= 0; i--) { + const row = data[i]; + + if (typeof row === 'object' && row !== null && row.is_total_row) { + totalRow = row; + break; + } + } + + // If no total row found, create one + if (!totalRow) { + totalRow = { is_total_row: true }; + data.push(totalRow); + } + + // Set the aggregated value + if (typeof totalRow === 'object') { + totalRow[fieldName] = result; + } + } + + /** + * Calculate sum of a field across all rows + * @param {Array} data - Report data + * @param {string} fieldName - Field to sum + * @returns {number} Sum value + * @private + */ + calculateSum(data, fieldName) { + let sum = 0; + + data.forEach(row => { + if (row.is_total_row) return; // Skip total rows + + const value = this.getRowValue(row, fieldName); + if (typeof value === 'number' && !isNaN(value)) { + sum += value; + } + }); + + return sum; + } + + /** + * Calculate average of a field across all rows + * @param {Array} data - Report data + * @param {string} fieldName - Field to average + * @returns {number} Average value + * @private + */ + calculateAverage(data, fieldName) { + let sum = 0; + let count = 0; + + data.forEach(row => { + if (row.is_total_row) return; // Skip total rows + + const value = this.getRowValue(row, fieldName); + if (typeof value === 'number' && !isNaN(value)) { + sum += value; + count++; + } + }); + + return count > 0 ? sum / count : 0; + } + + /** + * Calculate minimum value of a field across all rows + * @param {Array} data - Report data + * @param {string} fieldName - Field to check + * @returns {number} Minimum value + * @private + */ + calculateMin(data, fieldName) { + let min = Infinity; + + data.forEach(row => { + if (row.is_total_row) return; // Skip total rows + + const value = this.getRowValue(row, fieldName); + if (typeof value === 'number' && !isNaN(value) && value < min) { + min = value; + } + }); + + return min === Infinity ? 0 : min; + } + + /** + * Calculate maximum value of a field across all rows + * @param {Array} data - Report data + * @param {string} fieldName - Field to check + * @returns {number} Maximum value + * @private + */ + calculateMax(data, fieldName) { + let max = -Infinity; + + data.forEach(row => { + if (row.is_total_row) return; // Skip total rows + + const value = this.getRowValue(row, fieldName); + if (typeof value === 'number' && !isNaN(value) && value > max) { + max = value; + } + }); + + return max === -Infinity ? 0 : max; + } + + /** + * Count non-empty values of a field across all rows + * @param {Array} data - Report data + * @param {string} fieldName - Field to count + * @returns {number} Count of non-empty values + * @private + */ + calculateCount(data, fieldName) { + let count = 0; + + data.forEach(row => { + if (row.is_total_row) return; // Skip total rows + + const value = this.getRowValue(row, fieldName); + if (value !== null && value !== undefined && value !== '') { + count++; + } + }); + + return count; + } + + /** + * Get a value from a row, handling both array and object formats + * @param {Object|Array} row - Report row + * @param {string} fieldName - Field name + * @returns {*} Field value + * @private + */ + getRowValue(row, fieldName) { + if (Array.isArray(row)) { + // Find the column index + const columns = this.options.editor.reportColumns || []; + const colIndex = columns.findIndex(c => c.fieldname === fieldName); + + return colIndex !== -1 ? row[colIndex] : null; + } else if (typeof row === 'object' && row !== null) { + return row[fieldName]; + } + + return null; + } + + /** + * Integrate with a Frappe report + * @param {Object} report - Frappe report instance + * @param {string} reportName - Report name + * @param {string} [reportType='report'] - Report type + * @returns {boolean} Success status + * @public + */ + integrateWithReport(report, reportName, reportType = 'report') { + if (!report || !reportName) { + return false; + } + + try { + // Store reference to the report + this.currentReport = report; + this.currentReportName = reportName; + this.currentReportType = reportType; + + // Save original functions + if (!report._original_get_data) { + report._original_get_data = report.get_data; + + // Override get_data to apply formulas + report.get_data = (...args) => { + const result = report._original_get_data.apply(report, args); + + if (result && typeof result.then === 'function') { + // It's a promise + return result.then(response => { + const data = response.data || response; + const columns = response.columns || report.columns; + + // Apply formulas to the data + const modified = this.applyFormulasToReport( + data, + columns, + reportName, + reportType + ); + + // Update the response + if (typeof response === 'object') { + response.data = modified.data; + response.columns = modified.columns; + return response; + } + + return modified.data; + }); + } else { + // It's direct data + const data = result || []; + const columns = report.columns || []; + + // Apply formulas to the data + const modified = this.applyFormulasToReport( + data, + columns, + reportName, + reportType + ); + + // Update the report columns + report.columns = modified.columns; + + return modified.data; + } + }; + } + + // Also hook into refresh if available + if (report.refresh && !report._original_refresh) { + report._original_refresh = report.refresh; + + report.refresh = () => { + // Call original refresh + const result = report._original_refresh.apply(report); + + // Add formula controls to the report + setTimeout(() => { + this.addFormulaControlsToReport(report, reportName, reportType); + }, 500); + + return result; + }; + } + + return true; + + } catch (error) { + console.error('Error integrating with report:', error); + return false; + } + } + + /** + * Add formula controls to a report UI + * @param {Object} report - Frappe report instance + * @param {string} reportName - Report name + * @param {string} reportType - Report type + * @private + */ + addFormulaControlsToReport(report, reportName, reportType) { + try { + // Check if controls already exist + if (report.$formulaControls) return; + + // Find report toolbar + const $toolbar = $(report.wrapper).find('.report-action-buttons, .grid-report-action-buttons'); + if (!$toolbar.length) return; + + // Create formula button + const $formulaBtn = $(` + + `); + + // Add button to toolbar + $toolbar.append($formulaBtn); + + // Store reference + report.$formulaControls = $formulaBtn; + + // Add click handler + $formulaBtn.on('click', () => { + this.showReportFormulaDialog(reportName, reportType, report); + }); + + } catch (error) { + console.error('Error adding formula controls to report:', error); + } + } + + /** + * Show dialog to manage report formulas + * @param {string} reportName - Report name + * @param {string} reportType - Report type + * @param {Object} report - Report instance + * @private + */ + showReportFormulaDialog(reportName, reportType, report) { + const formulas = this.getFormulasForReport(reportName, reportType); + + // Get available columns + const columns = report.columns || []; + const columnOptions = columns.map(col => ({ + label: col.label || col.fieldname, + value: col.fieldname + })); + + // Create dialog + const dialog = new frappe.ui.Dialog({ + title: __('Report Formulas'), + fields: [ + { + fieldtype: 'HTML', + fieldname: 'formula_list', + label: __('Formulas'), + options: `
` + }, + { + fieldtype: 'Section Break', + label: __('Add New Formula') + }, + { + fieldtype: 'Data', + fieldname: 'name', + label: __('Name'), + reqd: true + }, + { + fieldtype: 'Code', + fieldname: 'formula', + label: __('Formula'), + options: 'JavaScript', + description: __('Use row to access the current row. Example: row.amount * 1.1') + }, + { + fieldtype: 'Column Break' + }, + { + fieldtype: 'Data', + fieldname: 'fieldName', + label: __('Field Name'), + description: __('Unique identifier for this formula field') + }, + { + fieldtype: 'Data', + fieldname: 'columnLabel', + label: __('Column Label'), + description: __('Display label for the column') + }, + { + fieldtype: 'Section Break' + }, + { + fieldtype: 'Select', + fieldname: 'position', + label: __('Position'), + options: [ + { label: __('At Start'), value: 'start' }, + { label: __('At End'), value: 'end' }, + { label: __('After Field'), value: 'after' } + ], + default: 'end' + }, + { + fieldtype: 'Select', + fieldname: 'positionField', + label: __('After Field'), + options: columnOptions, + depends_on: 'eval:doc.position === "after"' + }, + { + fieldtype: 'Column Break' + }, + { + fieldtype: 'Check', + fieldname: 'is_numeric', + label: __('Numeric'), + default: 1 + }, + { + fieldtype: 'Select', + fieldname: 'format', + label: __('Format'), + options: [ + { label: __('None'), value: '' }, + { label: __('Currency'), value: 'Currency' }, + { label: __('Percent'), value: 'Percent' }, + { label: __('Number'), value: 'Number' } + ], + default: '' + }, + { + fieldtype: 'Int', + fieldname: 'precision', + label: __('Precision'), + default: 2 + }, + { + fieldtype: 'Section Break' + }, + { + fieldtype: 'Select', + fieldname: 'aggregation', + label: __('Aggregation'), + options: [ + { label: __('None'), value: '' }, + { label: __('Sum'), value: 'sum' }, + { label: __('Average'), value: 'avg' }, + { label: __('Minimum'), value: 'min' }, + { label: __('Maximum'), value: 'max' }, + { label: __('Count'), value: 'count' } + ], + default: '' + }, + { + fieldtype: 'Text', + fieldname: 'description', + label: __('Description') + } + ], + primary_action_label: __('Add Formula'), + primary_action: (values) => { + // Generate field name if not provided + if (!values.fieldName) { + values.fieldName = `calculated_${Math.random().toString(36).substr(2, 6)}`; + } + + // Add the formula + this.addReportFormula({ + name: values.name, + formula: values.formula, + reportType: reportType, + reportName: reportName, + fieldName: values.fieldName, + columnLabel: values.columnLabel || values.name, + position: values.position, + positionField: values.position === 'after' ? values.positionField : null, + is_numeric: values.is_numeric, + format: values.format, + precision: values.precision, + aggregation: values.aggregation, + description: values.description + }); + + // Refresh formulas list + this.refreshFormulasList(dialog); + + // Clear form + dialog.set_values({ + name: '', + formula: '', + fieldName: '', + columnLabel: '', + description: '' + }); + + // Refresh report + if (report && report.refresh) { + report.refresh(); + } + } + }); + + // Render formulas list + dialog.show(); + this.refreshFormulasList(dialog); + } + + /** + * Refresh the formulas list in the dialog + * @param {Object} dialog - Frappe dialog instance + * @private + */ + refreshFormulasList(dialog) { + const $list = dialog.$wrapper.find('.formula-list'); + const formulas = this.getFormulasForReport( + this.currentReportName, + this.currentReportType + ); + + if (formulas.length === 0) { + $list.html(`
${__('No formulas defined for this report')}
`); + return; + } + + let html = '
'; + + formulas.forEach((formula, index) => { + html += ` +
+
+ ${formula.name} + ${formula.columnLabel} (${formula.fieldName}) +
+ + + +
+
+
${formula.formula}
+ ${formula.description ? `
${formula.description}
` : ''} +
+ `; + }); + + html += '
'; + $list.html(html); + + // Add event handlers + $list.find('.formula-edit-btn').on('click', (e) => { + const $item = $(e.target).closest('.formula-list-item'); + const id = $item.data('id'); + this.editReportFormula(id, dialog); + }); + + $list.find('.formula-toggle-btn').on('click', (e) => { + const $item = $(e.target).closest('.formula-list-item'); + const id = $item.data('id'); + const formula = this.getReportFormula(id); + + if (formula) { + this.setReportFormulaEnabled(id, !formula.enabled); + this.refreshFormulasList(dialog); + + // Refresh report + if (this.currentReport && this.currentReport.refresh) { + this.currentReport.refresh(); + } + } + }); + + $list.find('.formula-delete-btn').on('click', (e) => { + const $item = $(e.target).closest('.formula-list-item'); + const id = $item.data('id'); + + frappe.confirm( + __('Delete this formula?'), + () => { + this.deleteReportFormula(id); + this.refreshFormulasList(dialog); + + // Refresh report + if (this.currentReport && this.currentReport.refresh) { + this.currentReport.refresh(); + } + } + ); + }); + } + + /** + * Edit a report formula + * @param {string} id - Formula ID + * @param {Object} dialog - Frappe dialog instance + * @private + */ + editReportFormula(id, dialog) { + const formula = this.getReportFormula(id); + if (!formula) return; + + // Switch dialog to edit mode + dialog.set_title(__('Edit Formula')); + + // Set values + dialog.set_values({ + name: formula.name, + formula: formula.formula, + fieldName: formula.fieldName, + columnLabel: formula.columnLabel, + position: formula.position, + positionField: formula.positionField, + is_numeric: formula.is_numeric, + format: formula.format, + precision: formula.precision, + aggregation: formula.aggregation, + description: formula.description + }); + + // Change primary action + dialog.set_primary_action_label(__('Update Formula')); + + // Store original primary action + const originalAction = dialog.primary_action; + + // Set new primary action + dialog.primary_action = (values) => { + // Update the formula + this.updateReportFormula(id, { + name: values.name, + formula: values.formula, + fieldName: values.fieldName, + columnLabel: values.columnLabel || values.name, + position: values.position, + positionField: values.position === 'after' ? values.positionField : null, + is_numeric: values.is_numeric, + format: values.format, + precision: values.precision, + aggregation: values.aggregation, + description: values.description + }); + + // Refresh formulas list + this.refreshFormulasList(dialog); + + // Reset dialog + dialog.set_title(__('Report Formulas')); + dialog.set_primary_action_label(__('Add Formula')); + dialog.primary_action = originalAction; + + // Clear form + dialog.set_values({ + name: '', + formula: '', + fieldName: '', + columnLabel: '', + description: '' + }); + + // Refresh report + if (this.currentReport && this.currentReport.refresh) { + this.currentReport.refresh(); + } + }; + } + + /** + * Handle formula changes + * @private + */ + handleChange() { + if (typeof this.options.onChange === 'function') { + this.options.onChange(this.reportFormulas); + } + } + + /** + * Import report formulas from JSON + * @param {string|Object} json - JSON string or object + * @returns {boolean} Success status + * @public + */ + importReportFormulas(json) { + try { + let data; + + if (typeof json === 'string') { + data = JSON.parse(json); + } else if (typeof json === 'object' && json !== null) { + data = json; + } else { + throw new Error(__('Invalid import data format')); + } + + if (!Array.isArray(data)) { + throw new Error(__('Import data must be an array')); + } + + // Validate each formula + const validFormulas = data.filter(formula => + typeof formula === 'object' && + formula !== null && + typeof formula.reportName === 'string' && + typeof formula.fieldName === 'string' + ); + + if (validFormulas.length === 0) { + throw new Error(__('No valid report formulas found in import data')); + } + + // Replace report formulas + this.reportFormulas = validFormulas.map(formula => ({ + id: formula.id || `report_formula_${Date.now()}`, + name: formula.name || __('Unnamed Report Formula'), + formula: formula.formula || '', + reportType: formula.reportType || 'report', + reportName: formula.reportName, + fieldName: formula.fieldName, + columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'), + position: formula.position || 'end', + positionField: formula.positionField || null, + is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true, + format: formula.format || null, + enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, + description: formula.description || '', + precision: formula.precision !== undefined ? formula.precision : 2, + aggregation: formula.aggregation || null + })); + + this.debouncedOnChange(); + return true; + + } catch (error) { + console.error('Error importing report formulas:', error); + return false; + } + } + + /** + * Export report formulas to JSON + * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON + * @returns {string} JSON string + * @public + */ + exportReportFormulas(prettyPrint = false) { + try { + return JSON.stringify(this.reportFormulas, null, prettyPrint ? 2 : 0); + } catch (error) { + console.error('Error exporting report formulas:', error); + return '[]'; + } + } +} + +export default FormulaEditorReportFields; \ No newline at end of file diff --git a/formula_editor/public/js/table_row_formulas.js b/formula_editor/public/js/table_row_formulas.js new file mode 100644 index 0000000..b41efdd --- /dev/null +++ b/formula_editor/public/js/table_row_formulas.js @@ -0,0 +1,851 @@ +/** + * Formula Editor Table Row Formulas + * Handles formulas for table rows in documents + */ + +import { + safeEval, + validateFormula, + extractReferencedFields, + formatErrorMessage, + debounce, + isEmpty, + deepClone, + showToast +} from './utils'; + +class FormulaEditorTableRows { + /** + * Create a new FormulaEditorTableRows instance + * @param {Object} options - Configuration options + * @param {Object} options.editor - Reference to the main FormulaEditor instance + * @param {Array} [options.tableFormulas=[]] - Initial table formulas + * @param {Function} [options.onChange] - Callback for formula changes + */ + constructor(options) { + this.options = Object.assign({ + editor: null, + tableFormulas: [], + onChange: null + }, options); + + // Validate required options + if (!this.options.editor) { + throw new Error(__('Editor instance is required for FormulaEditorTableRows')); + } + + // Initialize properties + this.editor = this.options.editor; + this.tableFormulas = deepClone(this.options.tableFormulas || []); + this.childTableFields = []; + this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); + + // Initialize + this.init(); + } + + /** + * Initialize table formulas + * @private + */ + init() { + // Find all child table fields in the doctype + this.loadChildTableFields(); + + // Ensure all table formulas have proper structure + this.validateTableFormulas(); + } + + /** + * Load child table fields from the doctype + * @private + */ + loadChildTableFields() { + const allFields = this.editor.fields || []; + + // Filter to only table type fields + this.childTableFields = allFields.filter(field => + field.type === 'Table' && field.options + ); + } + + /** + * Validate table formulas structure and add missing properties + * @private + */ + validateTableFormulas() { + if (!Array.isArray(this.tableFormulas)) { + this.tableFormulas = []; + return; + } + + // Process each table formula to ensure it has all required properties + this.tableFormulas = this.tableFormulas.map(formula => { + // Skip if not an object + if (typeof formula !== 'object' || formula === null) { + return null; + } + + // Check if table field exists + const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field); + if (!tableField) { + return null; + } + + // Ensure required properties + return { + id: formula.id || `${formula.table_field}_${formula.target_field}_formula`, + name: formula.name || __('Formula for {0}', [formula.target_field]), + table_field: formula.table_field, + target_field: formula.target_field, + source_fields: Array.isArray(formula.source_fields) ? formula.source_fields : [], + formula: formula.formula || '', + child_doctype: tableField.options, + enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, + apply_on: formula.apply_on || 'change', // change, save, manual + is_cumulative: !!formula.is_cumulative, + description: formula.description || '' + }; + }).filter(Boolean); // Remove invalid formulas + } + + /** + * Get all table formulas + * @returns {Array} List of table formulas + * @public + */ + getTableFormulas() { + return deepClone(this.tableFormulas); + } + + /** + * Get a table formula by ID + * @param {string} id - Formula ID + * @returns {Object|null} Formula object or null if not found + * @public + */ + getTableFormula(id) { + const formula = this.tableFormulas.find(f => f.id === id); + return formula ? deepClone(formula) : null; + } + + /** + * Get table formulas for a specific table field + * @param {string} tableField - Table field name + * @returns {Array} List of formulas for the table field + * @public + */ + getFormulasForTable(tableField) { + return this.tableFormulas.filter(f => f.table_field === tableField).map(f => deepClone(f)); + } + + /** + * Add a new table formula + * @param {Object} formula - Formula data + * @returns {string} New formula ID + * @public + */ + addTableFormula(formula = {}) { + if (!formula.table_field || !formula.target_field) { + throw new Error(__('Table field and target field are required')); + } + + // Check if table field exists + const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field); + if (!tableField) { + throw new Error(__('Table field {0} not found', [formula.table_field])); + } + + const newFormula = { + id: formula.id || `${formula.table_field}_${formula.target_field}_formula`, + name: formula.name || __('Formula for {0}', [formula.target_field]), + table_field: formula.table_field, + target_field: formula.target_field, + source_fields: Array.isArray(formula.source_fields) ? deepClone(formula.source_fields) : [], + formula: formula.formula || '', + child_doctype: tableField.options, + enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, + apply_on: formula.apply_on || 'change', + is_cumulative: !!formula.is_cumulative, + description: formula.description || '' + }; + + this.tableFormulas.push(newFormula); + this.debouncedOnChange(); + + return newFormula.id; + } + + /** + * Update a table formula + * @param {string} id - Formula ID + * @param {Object} updates - Properties to update + * @returns {boolean} Success status + * @public + */ + updateTableFormula(id, updates) { + const index = this.tableFormulas.findIndex(f => f.id === id); + if (index === -1) return false; + + // Clone the formula + const formula = deepClone(this.tableFormulas[index]); + + // Apply updates + Object.keys(updates).forEach(key => { + // Handle special properties + if (key === 'source_fields' && Array.isArray(updates.source_fields)) { + formula.source_fields = deepClone(updates.source_fields); + } else if (key === 'table_field') { + // If table field is updated, verify it exists + const tableField = this.childTableFields.find(f => f.fieldname === updates.table_field); + if (tableField) { + formula.table_field = updates.table_field; + formula.child_doctype = tableField.options; + } + } else if (key !== 'id' && key !== 'child_doctype') { // Don't allow changing ID or child_doctype directly + formula[key] = updates[key]; + } + }); + + // Update the formula + this.tableFormulas[index] = formula; + this.debouncedOnChange(); + + return true; + } + + /** + * Delete a table formula + * @param {string} id - Formula ID + * @returns {boolean} Success status + * @public + */ + deleteTableFormula(id) { + const index = this.tableFormulas.findIndex(f => f.id === id); + if (index === -1) return false; + + this.tableFormulas.splice(index, 1); + this.debouncedOnChange(); + + return true; + } + + /** + * Enable/disable a table formula + * @param {string} id - Formula ID + * @param {boolean} enabled - Whether to enable the formula + * @returns {boolean} Success status + * @public + */ + setTableFormulaEnabled(id, enabled) { + return this.updateTableFormula(id, { enabled: !!enabled }); + } + + /** + * Validate a table formula + * @param {string} id - Formula ID + * @returns {Object} Validation result with isValid and error + * @public + */ + validateTableFormula(id) { + const formula = this.getTableFormula(id); + if (!formula) { + return { isValid: false, error: __('Formula not found') }; + } + + if (!formula.formula.trim()) { + return { isValid: true, error: null }; + } + + // Use basic formula validation + return validateFormula(formula.formula); + } + + /** + * Get field options for a child table + * @param {string} childDoctype - Child doctype name + * @returns {Promise} List of field options + * @public + */ + async getChildTableFields(childDoctype) { + try { + let meta; + + // Try to get meta from cache + meta = frappe.get_meta(childDoctype); + + if (!meta) { + // If not in cache, fetch it + meta = await frappe.model.with_doctype(childDoctype); + } + + if (!meta || !meta.fields) { + throw new Error(__('Could not fetch metadata for {0}', [childDoctype])); + } + + const fields = []; + + // Add fields from the child DocType + for (const field of meta.fields) { + // Skip certain field types + if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { + continue; + } + + fields.push({ + fieldname: field.fieldname, + label: field.label || field.fieldname, + type: field.fieldtype, + options: field.options, + is_child_table: field.fieldtype === 'Table' + }); + } + + return fields; + + } catch (error) { + console.error(`Error getting fields for child doctype ${childDoctype}:`, error); + return []; + } + } + + /** + * Apply formulas to a table + * @param {Object} doc - Parent document + * @param {string} tableField - Table field name + * @param {Object} [options] - Options for applying formulas + * @param {boolean} [options.silent=false] - Whether to suppress notifications + * @returns {Promise} Result with success status and error if any + * @public + */ + async applyTableFormulas(doc, tableField, options = {}) { + const silent = options.silent || false; + + if (!doc || !tableField || !doc[tableField] || !Array.isArray(doc[tableField])) { + return { success: false, error: __('Invalid table data') }; + } + + try { + // Get formulas for this table + const formulas = this.getFormulasForTable(tableField); + + if (formulas.length === 0) { + return { success: true, message: __('No formulas to apply') }; + } + + // Apply each formula to all rows + for (const formula of formulas) { + if (!formula.enabled) continue; + + for (let rowIdx = 0; rowIdx < doc[tableField].length; rowIdx++) { + await this.applyFormulaToRow(formula, doc, rowIdx); + } + } + + if (!silent) { + showToast(__('Applied {0} formulas to {1} rows', [formulas.length, doc[tableField].length]), 'success'); + } + + return { success: true }; + + } catch (error) { + console.error('Error applying table formulas:', error); + if (!silent) { + showToast(__('Error applying table formulas: {0}', [formatErrorMessage(error)]), 'error'); + } + return { success: false, error: formatErrorMessage(error) }; + } + } + + /** + * Apply a formula to a specific row + * @param {Object} formula - Formula configuration + * @param {Object} doc - Parent document + * @param {number} rowIdx - Row index + * @returns {Promise} Success status + * @private + */ + async applyFormulaToRow(formula, doc, rowIdx) { + try { + if (!formula.enabled || !formula.formula.trim()) { + return true; + } + + const table = doc[formula.table_field]; + if (!table || !Array.isArray(table) || rowIdx >= table.length) { + return false; + } + + const row = table[rowIdx]; + const previousRows = table.slice(0, rowIdx); + const allRows = table; + + // Create evaluation context + const context = { + doc: doc, + row: row, + rowIdx: rowIdx, + previousRows: previousRows, + allRows: allRows, + frappe: frappe + }; + + // Evaluate the formula + const result = await this.evaluateRowFormula(formula.formula, context); + + // Apply the result to the target field + if (result !== undefined) { + row[formula.target_field] = result; + + // If this is a cumulative formula, propagate changes to subsequent rows + if (formula.is_cumulative) { + await this.propagateCumulativeChanges(formula, doc, rowIdx); + } + } + + return true; + + } catch (error) { + console.error(`Error applying formula to row ${rowIdx}:`, error); + return false; + } + } + + /** + * Evaluate a formula in the context of a row + * @param {string} formula - Formula to evaluate + * @param {Object} context - Evaluation context + * @returns {Promise<*>} Evaluation result + * @private + */ + async evaluateRowFormula(formula, context) { + return safeEval(formula, context); + } + + /** + * Propagate cumulative changes to subsequent rows + * @param {Object} formula - Formula configuration + * @param {Object} doc - Parent document + * @param {number} startRowIdx - Starting row index + * @returns {Promise} + * @private + */ + async propagateCumulativeChanges(formula, doc, startRowIdx) { + const table = doc[formula.table_field]; + if (!table || !Array.isArray(table) || startRowIdx >= table.length - 1) { + return; + } + + // Apply formula to all subsequent rows + for (let rowIdx = startRowIdx + 1; rowIdx < table.length; rowIdx++) { + await this.applyFormulaToRow(formula, doc, rowIdx); + } + } + + /** + * Set up event listeners for a form + * @param {frappe.ui.form.Form} form - Frappe form instance + * @public + */ + attachToForm(form) { + if (!form) return; + + // Store the current form + this.currentForm = form; + + // Set up table field change listeners + this.childTableFields.forEach(tableField => { + const fieldName = tableField.fieldname; + + // Get the grid for this table + const grid = form.fields_dict[fieldName]?.grid; + if (!grid) return; + + // Save original grid row functions to extend them + if (!grid._original_refresh) { + grid._original_refresh = grid.refresh; + grid.refresh = () => { + grid._original_refresh(); + this.onGridRefresh(grid, fieldName, form); + }; + } + + if (!grid._original_add_new_row) { + grid._original_add_new_row = grid.add_new_row; + grid.add_new_row = (idx, callback, from_bottom) => { + grid._original_add_new_row(idx, (row) => { + if (callback) callback(row); + this.onRowAdded(grid, row, fieldName, form); + }, from_bottom); + }; + } + + if (!grid._original_setup_columns) { + grid._original_setup_columns = grid.setup_columns; + grid.setup_columns = () => { + grid._original_setup_columns(); + this.setupGridColumns(grid, fieldName, form); + }; + } + }); + + // Set up form save listener + if (!form._tableFormulas_original_save) { + form._tableFormulas_original_save = form.save; + form.save = (save_action, callback, btn, on_error) => { + // Apply 'save' formulas + this.applyAllFormulasOnSave(form).then(() => { + // Call original save method + form._tableFormulas_original_save(save_action, callback, btn, on_error); + }).catch(error => { + console.error('Error applying table formulas on save:', error); + // Still call original save + form._tableFormulas_original_save(save_action, callback, btn, on_error); + }); + }; + } + } + + /** + * Remove event listeners from a form + * @param {frappe.ui.form.Form} form - Frappe form instance + * @public + */ + detachFromForm(form) { + if (!form) return; + + // Restore original grid functions + this.childTableFields.forEach(tableField => { + const fieldName = tableField.fieldname; + const grid = form.fields_dict[fieldName]?.grid; + + if (!grid) return; + + if (grid._original_refresh) { + grid.refresh = grid._original_refresh; + delete grid._original_refresh; + } + + if (grid._original_add_new_row) { + grid.add_new_row = grid._original_add_new_row; + delete grid._original_add_new_row; + } + + if (grid._original_setup_columns) { + grid.setup_columns = grid._original_setup_columns; + delete grid._original_setup_columns; + } + }); + + // Restore original save method + if (form._tableFormulas_original_save) { + form.save = form._tableFormulas_original_save; + delete form._tableFormulas_original_save; + } + + // Clear current form reference + this.currentForm = null; + } + + /** + * Handle grid refresh event + * @param {frappe.ui.form.Grid} grid - Grid instance + * @param {string} fieldName - Table field name + * @param {frappe.ui.form.Form} form - Form instance + * @private + */ + onGridRefresh(grid, fieldName, form) { + // Set up row event handlers + grid.grid_rows.forEach(row => { + this.setupRowEvents(row, fieldName, form); + }); + } + + /** + * Handle row added event + * @param {frappe.ui.form.Grid} grid - Grid instance + * @param {frappe.ui.form.GridRow} row - Grid row instance + * @param {string} fieldName - Table field name + * @param {frappe.ui.form.Form} form - Form instance + * @private + */ + onRowAdded(grid, row, fieldName, form) { + // Set up events for the new row + this.setupRowEvents(row, fieldName, form); + + // Apply 'change' formulas to the new row + setTimeout(() => { + const formulas = this.getFormulasForTable(fieldName).filter(f => + f.enabled && f.apply_on === 'change' + ); + + if (formulas.length > 0) { + const rowIdx = row.doc.idx - 1; + + // Apply each formula + formulas.forEach(formula => { + this.applyFormulaToRow(formula, form.doc, rowIdx) + .then(() => { + // Refresh the row to show updated values + row.refresh(); + }) + .catch(error => { + console.error(`Error applying formula to new row:`, error); + }); + }); + } + }, 100); + } + + /** + * Set up events for a grid row + * @param {frappe.ui.form.GridRow} row - Grid row instance + * @param {string} fieldName - Table field name + * @param {frappe.ui.form.Form} form - Form instance + * @private + */ + setupRowEvents(row, fieldName, form) { + // Skip if already set up + if (row._formulaEventsSetup) return; + + // Get formulas for this table + const formulas = this.getFormulasForTable(fieldName).filter(f => + f.enabled && f.apply_on === 'change' + ); + + if (formulas.length === 0) return; + + // Set up field change handlers + const sourceFields = new Set(); + formulas.forEach(formula => { + formula.source_fields.forEach(field => { + sourceFields.add(field); + }); + + // Also add fields referenced in the formula + const referencedFields = extractReferencedFields(formula.formula, []); + referencedFields.forEach(field => { + if (field.startsWith('row.')) { + const rowField = field.substring(4); + sourceFields.add(rowField); + } + }); + }); + + // For each source field, set up a change handler + sourceFields.forEach(fieldName => { + const field = row.fields_dict[fieldName]; + if (!field) return; + + // If the field already has our wrapper, don't add another one + if (field._formulaChangeHandlerAttached) return; + + // Get the control's change function + const controlProto = Object.getPrototypeOf(field); + if (!controlProto || !controlProto.set_input) return; + + // Save original set_input function + field._original_set_input = field.set_input; + + // Override set_input to catch changes + field.set_input = function() { + const oldValue = field.value; + + // Call original function + field._original_set_input.apply(field, arguments); + + const newValue = field.value; + + // If value changed, trigger our handler + if (oldValue !== newValue) { + row._formula_on_field_change(fieldName, newValue, oldValue); + } + }; + + field._formulaChangeHandlerAttached = true; + }); + + // Add custom field change handler to the row + row._formula_on_field_change = debounce((changedField, newValue, oldValue) => { + const rowIdx = row.doc.idx - 1; + + // Find formulas that depend on this field + const relevantFormulas = formulas.filter(formula => + formula.source_fields.includes(changedField) || + formula.formula.includes(`row.${changedField}`) + ); + + if (relevantFormulas.length === 0) return; + + // Apply each relevant formula + relevantFormulas.forEach(formula => { + this.applyFormulaToRow(formula, form.doc, rowIdx) + .then(() => { + // Refresh the row to show updated values + row.refresh(); + }) + .catch(error => { + console.error(`Error applying formula after field change:`, error); + }); + }); + }, 300); + + row._formulaEventsSetup = true; + } + + /** + * Set up grid columns for better formula UX + * @param {frappe.ui.form.Grid} grid - Grid instance + * @param {string} fieldName - Table field name + * @param {frappe.ui.form.Form} form - Form instance + * @private + */ + setupGridColumns(grid, fieldName, form) { + // Get formulas for this table + const formulas = this.getFormulasForTable(fieldName); + + // Find columns that are calculated by formulas + formulas.forEach(formula => { + if (!formula.enabled) return; + + const colField = grid.grid_columns_by_fieldname[formula.target_field]; + if (!colField) return; + + // Add a class to indicate it's calculated + const headerElement = colField.header_field; + if (headerElement && headerElement.classList) { + headerElement.classList.add('formula-calculated-field'); + + // Add tooltip + headerElement.title = __('This field is calculated by a formula'); + + // Add formula info if grid has a datatable + if (grid.datatable) { + const colIdx = grid.datatable.getColumnIndex(formula.target_field); + if (colIdx !== -1) { + const col = grid.datatable.datamanager.columns[colIdx]; + if (col) { + col.formula = formula.formula; + col.isFormula = true; + } + } + } + } + }); + } + + /** + * Apply all formulas that should run on save + * @param {frappe.ui.form.Form} form - Frappe form instance + * @returns {Promise} + * @private + */ + async applyAllFormulasOnSave(form) { + if (!form) return; + + // Process each table field + for (const tableField of this.childTableFields) { + const fieldName = tableField.fieldname; + + // Get 'save' formulas for this table + const formulas = this.getFormulasForTable(fieldName).filter(f => + f.enabled && f.apply_on === 'save' + ); + + if (formulas.length > 0) { + // Apply formulas to the table + await this.applyTableFormulas(form.doc, fieldName, { silent: true }); + } + } + } + + /** + * Handle formula changes + * @private + */ + handleChange() { + if (typeof this.options.onChange === 'function') { + this.options.onChange(this.tableFormulas); + } + } + + /** + * Import table formulas from JSON + * @param {string|Object} json - JSON string or object + * @returns {boolean} Success status + * @public + */ + importTableFormulas(json) { + try { + let data; + + if (typeof json === 'string') { + data = JSON.parse(json); + } else if (typeof json === 'object' && json !== null) { + data = json; + } else { + throw new Error(__('Invalid import data format')); + } + + if (!Array.isArray(data)) { + throw new Error(__('Import data must be an array')); + } + + // Validate each formula + const validFormulas = data.filter(formula => + typeof formula === 'object' && + formula !== null && + typeof formula.table_field === 'string' && + typeof formula.target_field === 'string' + ); + + if (validFormulas.length === 0) { + throw new Error(__('No valid table formulas found in import data')); + } + + // Replace table formulas + this.tableFormulas = validFormulas.map(formula => { + // Find table field in our list + const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field); + + return { + id: formula.id || `${formula.table_field}_${formula.target_field}_formula`, + name: formula.name || __('Formula for {0}', [formula.target_field]), + table_field: formula.table_field, + target_field: formula.target_field, + source_fields: Array.isArray(formula.source_fields) ? formula.source_fields : [], + formula: formula.formula || '', + child_doctype: tableField ? tableField.options : formula.child_doctype, + enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, + apply_on: formula.apply_on || 'change', + is_cumulative: !!formula.is_cumulative, + description: formula.description || '' + }; + }); + + this.debouncedOnChange(); + return true; + + } catch (error) { + console.error('Error importing table formulas:', error); + return false; + } + } + + /** + * Export table formulas to JSON + * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON + * @returns {string} JSON string + * @public + */ + exportTableFormulas(prettyPrint = false) { + try { + return JSON.stringify(this.tableFormulas, null, prettyPrint ? 2 : 0); + } catch (error) { + console.error('Error exporting table formulas:', error); + return '[]'; + } + } +} + +export default FormulaEditorTableRows; \ No newline at end of file diff --git a/formula_editor/public/js/triggers.js b/formula_editor/public/js/triggers.js new file mode 100644 index 0000000..0988c08 --- /dev/null +++ b/formula_editor/public/js/triggers.js @@ -0,0 +1,803 @@ +/** + * Formula Editor Triggers + * Handles trigger functionality for formula evaluation + */ + +import { + generateUniqueId, + validateFormula, + formatErrorMessage, + debounce, + safeEval, + isEmpty, + deepClone, + showToast +} from './utils'; + +class FormulaEditorTriggers { + /** + * Create a new FormulaEditorTriggers instance + * @param {Object} options - Configuration options + * @param {Object} options.editor - Reference to the main FormulaEditor instance + * @param {Array} [options.triggers=[]] - Initial triggers + * @param {Function} [options.onChange] - Callback for trigger changes + */ + constructor(options) { + this.options = Object.assign({ + editor: null, + triggers: [], + onChange: null + }, options); + + // Validate required options + if (!this.options.editor) { + throw new Error(__('Editor instance is required for FormulaEditorTriggers')); + } + + // Initialize properties + this.editor = this.options.editor; + this.triggers = deepClone(this.options.triggers || []); + this.activeTriggerId = null; + this.triggerFieldsCache = null; + this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); + + // Initialize + this.init(); + } + + /** + * Initialize triggers + * @private + */ + init() { + // Ensure all triggers have proper structure + this.validateTriggers(); + } + + /** + * Validate triggers structure and add missing properties + * @private + */ + validateTriggers() { + if (!Array.isArray(this.triggers)) { + this.triggers = []; + return; + } + + // Process each trigger to ensure it has all required properties + this.triggers = this.triggers.map(trigger => { + // Skip if not an object + if (typeof trigger !== 'object' || trigger === null) { + return null; + } + + // Ensure required properties + return { + id: trigger.id || generateUniqueId('trigger'), + name: trigger.name || __('Unnamed Trigger'), + description: trigger.description || '', + condition: trigger.condition || '', + action: trigger.action || '', + fields: Array.isArray(trigger.fields) ? trigger.fields : [], + enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true, + source_doctype: trigger.source_doctype || this.editor.options.doctype, + target_doctype: trigger.target_doctype || this.editor.options.doctype, + event: trigger.event || 'on_change' // on_change, on_save, on_submit, etc. + }; + }).filter(Boolean); // Remove invalid triggers + } + + /** + * Get all triggers + * @returns {Array} List of triggers + * @public + */ + getTriggers() { + return deepClone(this.triggers); + } + + /** + * Get a trigger by ID + * @param {string} id - Trigger ID + * @returns {Object|null} Trigger object or null if not found + * @public + */ + getTrigger(id) { + const trigger = this.triggers.find(t => t.id === id); + return trigger ? deepClone(trigger) : null; + } + + /** + * Get active trigger + * @returns {Object|null} Active trigger object or null if none active + * @public + */ + getActiveTrigger() { + if (!this.activeTriggerId) return null; + return this.getTrigger(this.activeTriggerId); + } + + /** + * Set active trigger + * @param {string} id - Trigger ID to set as active + * @returns {boolean} Success status + * @public + */ + setActiveTrigger(id) { + const trigger = this.getTrigger(id); + if (!trigger) return false; + + this.activeTriggerId = id; + return true; + } + + /** + * Add a new trigger + * @param {Object} trigger - Trigger data + * @returns {string} New trigger ID + * @public + */ + addTrigger(trigger = {}) { + const newTrigger = { + id: generateUniqueId('trigger'), + name: trigger.name || __('New Trigger'), + description: trigger.description || '', + condition: trigger.condition || '', + action: trigger.action || '', + fields: Array.isArray(trigger.fields) ? deepClone(trigger.fields) : [], + enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true, + source_doctype: trigger.source_doctype || this.editor.options.doctype, + target_doctype: trigger.target_doctype || this.editor.options.doctype, + event: trigger.event || 'on_change' + }; + + this.triggers.push(newTrigger); + this.activeTriggerId = newTrigger.id; + this.debouncedOnChange(); + + return newTrigger.id; + } + + /** + * Update a trigger + * @param {string} id - Trigger ID + * @param {Object} updates - Properties to update + * @returns {boolean} Success status + * @public + */ + updateTrigger(id, updates) { + const index = this.triggers.findIndex(t => t.id === id); + if (index === -1) return false; + + // Clone the trigger + const trigger = deepClone(this.triggers[index]); + + // Apply updates + Object.keys(updates).forEach(key => { + // Handle special properties + if (key === 'fields' && Array.isArray(updates.fields)) { + trigger.fields = deepClone(updates.fields); + } else if (key !== 'id') { // Don't allow changing the ID + trigger[key] = updates[key]; + } + }); + + // Update the trigger + this.triggers[index] = trigger; + this.debouncedOnChange(); + + return true; + } + + /** + * Delete a trigger + * @param {string} id - Trigger ID + * @returns {boolean} Success status + * @public + */ + deleteTrigger(id) { + const index = this.triggers.findIndex(t => t.id === id); + if (index === -1) return false; + + this.triggers.splice(index, 1); + + // Clear active trigger if it was deleted + if (this.activeTriggerId === id) { + this.activeTriggerId = this.triggers.length > 0 ? this.triggers[0].id : null; + } + + this.debouncedOnChange(); + return true; + } + + /** + * Enable/disable a trigger + * @param {string} id - Trigger ID + * @param {boolean} enabled - Whether to enable the trigger + * @returns {boolean} Success status + * @public + */ + setTriggerEnabled(id, enabled) { + return this.updateTrigger(id, { enabled: !!enabled }); + } + + /** + * Reorder triggers + * @param {Array} newOrder - Array of trigger IDs in the new order + * @returns {boolean} Success status + * @public + */ + reorderTriggers(newOrder) { + if (!Array.isArray(newOrder) || newOrder.length !== this.triggers.length) { + return false; + } + + // Check if all IDs exist + const validIds = new Set(this.triggers.map(t => t.id)); + const allIdsExist = newOrder.every(id => validIds.has(id)); + + if (!allIdsExist) return false; + + // Create new ordered array + const ordered = []; + for (const id of newOrder) { + const trigger = this.triggers.find(t => t.id === id); + if (trigger) ordered.push(deepClone(trigger)); + } + + this.triggers = ordered; + this.debouncedOnChange(); + + return true; + } + + /** + * Validate a trigger condition or action + * @param {string} formula - Formula to validate + * @param {string} type - 'condition' or 'action' + * @returns {Object} Validation result with isValid and error + * @public + */ + validateTriggerFormula(formula, type = 'condition') { + if (!formula.trim()) { + return { isValid: true, error: null }; + } + + // Use basic formula validation + return validateFormula(formula); + } + + /** + * Get available fields for a trigger + * @param {string} doctype - DocType to get fields for + * @returns {Array} List of available fields + * @public + */ + async getTriggerFields(doctype) { + // Use cache if available + if (this.triggerFieldsCache && this.triggerFieldsCache[doctype]) { + return deepClone(this.triggerFieldsCache[doctype]); + } + + try { + const fields = []; + let meta; + + // Try to get meta from cache + meta = frappe.get_meta(doctype); + + if (!meta) { + // If not in cache, fetch it + meta = await frappe.model.with_doctype(doctype); + } + + if (!meta || !meta.fields) { + throw new Error(__('Could not fetch metadata for {0}', [doctype])); + } + + // Add fields from the DocType + for (const field of meta.fields) { + // Skip certain field types + if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { + continue; + } + + fields.push({ + fieldname: field.fieldname, + label: field.label || field.fieldname, + type: field.fieldtype, + options: field.options, + is_child_table: field.fieldtype === 'Table' + }); + } + + // Cache the fields + if (!this.triggerFieldsCache) this.triggerFieldsCache = {}; + this.triggerFieldsCache[doctype] = deepClone(fields); + + return fields; + + } catch (error) { + console.error('Error getting trigger fields:', error); + return []; + } + } + + /** + * Test a trigger condition + * @param {string} id - Trigger ID + * @param {Object} [context] - Evaluation context + * @returns {Object} Test result with success, result, error + * @public + */ + testTriggerCondition(id, context = {}) { + const trigger = this.getTrigger(id); + if (!trigger) { + return { success: false, result: false, error: __('Trigger not found') }; + } + + if (!trigger.condition.trim()) { + return { success: true, result: true, error: null }; + } + + try { + // Create evaluation context + const evalContext = { + doc: this.editor.options.doc || {}, + frappe: frappe, + ...context + }; + + // Evaluate the condition + const result = safeEval(trigger.condition, evalContext); + + return { + success: true, + result: !!result, // Convert to boolean + error: null + }; + + } catch (error) { + console.error('Error testing trigger condition:', error); + return { + success: false, + result: false, + error: formatErrorMessage(error) + }; + } + } + + /** + * Test a trigger action + * @param {string} id - Trigger ID + * @param {Object} [context] - Evaluation context + * @returns {Object} Test result with success, result, error + * @public + */ + testTriggerAction(id, context = {}) { + const trigger = this.getTrigger(id); + if (!trigger) { + return { success: false, result: null, error: __('Trigger not found') }; + } + + if (!trigger.action.trim()) { + return { success: true, result: null, error: null }; + } + + try { + // Create evaluation context + const evalContext = { + doc: this.editor.options.doc || {}, + frappe: frappe, + ...context + }; + + // Evaluate the action + const result = safeEval(trigger.action, evalContext); + + return { + success: true, + result: result, + error: null + }; + + } catch (error) { + console.error('Error testing trigger action:', error); + return { + success: false, + result: null, + error: formatErrorMessage(error) + }; + } + } + + /** + * Execute a trigger + * @param {string} id - Trigger ID + * @param {Object} [context] - Evaluation context + * @returns {Object} Execution result with success, actionResult, error + * @public + */ + executeTrigger(id, context = {}) { + const trigger = this.getTrigger(id); + if (!trigger) { + return { success: false, actionResult: null, error: __('Trigger not found') }; + } + + // Skip disabled triggers + if (!trigger.enabled) { + return { success: true, actionResult: null, error: __('Trigger is disabled') }; + } + + // Check condition + const conditionResult = this.testTriggerCondition(id, context); + + if (!conditionResult.success) { + return { + success: false, + actionResult: null, + error: __('Condition evaluation failed: {0}', [conditionResult.error]) + }; + } + + // Skip if condition is not met + if (!conditionResult.result) { + return { + success: true, + actionResult: null, + error: __('Condition not met') + }; + } + + // Execute action + const actionResult = this.testTriggerAction(id, context); + + if (!actionResult.success) { + return { + success: false, + actionResult: null, + error: __('Action execution failed: {0}', [actionResult.error]) + }; + } + + return { + success: true, + actionResult: actionResult.result, + error: null + }; + } + + /** + * Execute all triggers for an event + * @param {string} event - Event name (e.g., 'on_change', 'on_save') + * @param {Object} [context] - Evaluation context + * @returns {Array} Execution results for each trigger + * @public + */ + executeTriggersForEvent(event, context = {}) { + // Filter triggers by event and doctype + const triggers = this.triggers.filter(trigger => + trigger.enabled && + trigger.event === event && + trigger.source_doctype === context.doctype + ); + + const results = []; + + // Execute each trigger + for (const trigger of triggers) { + const result = this.executeTrigger(trigger.id, context); + + results.push({ + triggerId: trigger.id, + triggerName: trigger.name, + ...result + }); + } + + return results; + } + + /** + * Set up event listeners for a form + * @param {frappe.ui.form.Form} form - Frappe form instance + * @public + */ + attachToForm(form) { + if (!form) return; + + // Store the current form + this.currentForm = form; + + // Set up field change listeners + form.fields.forEach(fieldObj => { + const field = fieldObj.df; + + // Skip certain field types + if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { + return; + } + + // Set up change listener + const fieldName = field.fieldname; + if (fieldName) { + const control = form.get_field(fieldName); + if (control && control.$input) { + // Create debounced handler for each field + const debouncedHandler = debounce(() => { + this.handleFieldChange(fieldName, form); + }, 300); + + // Save the original change event if exists + if (control.df._original_change) return; + control.df._original_change = control.df.change; + + // Set the new change event + control.df.change = () => { + // Call original event if exists + if (typeof control.df._original_change === 'function') { + control.df._original_change(); + } + + // Call the debounced handler + debouncedHandler(); + }; + } + } + }); + + // Set up form save listener + if (!form._original_save) { + form._original_save = form.save; + form.save = (save_action, callback, btn, on_error) => { + // Execute 'on_save' triggers + this.executeTriggersForEvent('on_save', { + doc: form.doc, + doctype: form.doctype, + form: form + }); + + // Call original save method + form._original_save(save_action, callback, btn, on_error); + }; + } + + // Set up form submit listener + if (!form._original_savesubmit) { + form._original_savesubmit = form.savesubmit; + form.savesubmit = (callback, btn, on_error) => { + // Execute 'on_submit' triggers + this.executeTriggersForEvent('on_submit', { + doc: form.doc, + doctype: form.doctype, + form: form + }); + + // Call original savesubmit method + form._original_savesubmit(callback, btn, on_error); + }; + } + } + + /** + * Remove event listeners from a form + * @param {frappe.ui.form.Form} form - Frappe form instance + * @public + */ + detachFromForm(form) { + if (!form) return; + + // Restore original field change events + form.fields.forEach(fieldObj => { + const field = fieldObj.df; + const fieldName = field.fieldname; + + if (fieldName) { + const control = form.get_field(fieldName); + if (control && control.df._original_change) { + control.df.change = control.df._original_change; + delete control.df._original_change; + } + } + }); + + // Restore original save method + if (form._original_save) { + form.save = form._original_save; + delete form._original_save; + } + + // Restore original savesubmit method + if (form._original_savesubmit) { + form.savesubmit = form._original_savesubmit; + delete form._original_savesubmit; + } + + // Clear current form reference + this.currentForm = null; + } + + /** + * Handle field change event + * @param {string} fieldName - Name of the changed field + * @param {frappe.ui.form.Form} form - Frappe form instance + * @private + */ + handleFieldChange(fieldName, form) { + if (!form) return; + + // Execute 'on_change' triggers with field context + const results = this.executeTriggersForEvent('on_change', { + doc: form.doc, + doctype: form.doctype, + form: form, + field: fieldName, + value: form.doc[fieldName] + }); + + // Refresh form if any trigger executed successfully + const shouldRefresh = results.some(result => result.success); + if (shouldRefresh) { + // Delay refresh to avoid conflicts + setTimeout(() => { + form.refresh(); + }, 100); + } + } + + /** + * Handle trigger changes + * @private + */ + handleChange() { + if (typeof this.options.onChange === 'function') { + this.options.onChange(this.triggers); + } + } + + /** + * Import triggers from JSON + * @param {string|Object} json - JSON string or object + * @returns {boolean} Success status + * @public + */ + importTriggers(json) { + try { + let data; + + if (typeof json === 'string') { + data = JSON.parse(json); + } else if (typeof json === 'object' && json !== null) { + data = json; + } else { + throw new Error(__('Invalid import data format')); + } + + if (!Array.isArray(data)) { + throw new Error(__('Import data must be an array')); + } + + // Validate each trigger + const validTriggers = data.filter(trigger => + typeof trigger === 'object' && + trigger !== null && + typeof trigger.name === 'string' + ); + + if (validTriggers.length === 0) { + throw new Error(__('No valid triggers found in import data')); + } + + // Replace triggers + this.triggers = validTriggers.map(trigger => ({ + id: trigger.id || generateUniqueId('trigger'), + name: trigger.name, + description: trigger.description || '', + condition: trigger.condition || '', + action: trigger.action || '', + fields: Array.isArray(trigger.fields) ? trigger.fields : [], + enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true, + source_doctype: trigger.source_doctype || this.editor.options.doctype, + target_doctype: trigger.target_doctype || this.editor.options.doctype, + event: trigger.event || 'on_change' + })); + + // Set first trigger as active + if (this.triggers.length > 0) { + this.activeTriggerId = this.triggers[0].id; + } else { + this.activeTriggerId = null; + } + + this.debouncedOnChange(); + return true; + + } catch (error) { + console.error('Error importing triggers:', error); + return false; + } + } + + /** + * Export triggers to JSON + * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON + * @returns {string} JSON string + * @public + */ + exportTriggers(prettyPrint = false) { + try { + return JSON.stringify(this.triggers, null, prettyPrint ? 2 : 0); + } catch (error) { + console.error('Error exporting triggers:', error); + return '[]'; + } + } + + /** + * Create default triggers for a form + * @param {string} doctype - DocType to create triggers for + * @returns {boolean} Success status + * @public + */ + async createDefaultTriggers(doctype) { + try { + if (this.triggers.length > 0) { + const confirmed = await new Promise(resolve => { + frappe.confirm( + __('This will replace all existing triggers. Continue?'), + () => resolve(true), + () => resolve(false) + ); + }); + + if (!confirmed) return false; + } + + // Get fields + const fields = await this.getTriggerFields(doctype); + + // Create default triggers for numeric fields + const numericFields = fields.filter(f => + ['Int', 'Float', 'Currency', 'Percent'].includes(f.type) + ); + + const defaultTriggers = []; + + for (const field of numericFields) { + // Skip certain fields like ID, name, etc. + if (['name', 'owner', 'creation', 'modified', 'modified_by', 'idx'].includes(field.fieldname)) { + continue; + } + + defaultTriggers.push({ + name: __('Validate {0}', [field.label || field.fieldname]), + description: __('Validate {0} is not negative', [field.label || field.fieldname]), + condition: `doc.${field.fieldname} < 0`, + action: `frappe.throw(__("${field.label || field.fieldname} cannot be negative"));`, + fields: [field.fieldname], + enabled: true, + source_doctype: doctype, + target_doctype: doctype, + event: 'on_change' + }); + } + + // Import the default triggers + if (defaultTriggers.length > 0) { + this.importTriggers(defaultTriggers); + showToast(__('Created {0} default triggers', [defaultTriggers.length]), 'success'); + return true; + } else { + showToast(__('No suitable fields found for default triggers'), 'warning'); + return false; + } + + } catch (error) { + console.error('Error creating default triggers:', error); + showToast(__('Error creating default triggers'), 'error'); + return false; + } + } +} + +export default FormulaEditorTriggers; \ No newline at end of file diff --git a/formula_editor/public/js/ui.js b/formula_editor/public/js/ui.js new file mode 100644 index 0000000..61d2e30 --- /dev/null +++ b/formula_editor/public/js/ui.js @@ -0,0 +1,1204 @@ +/** + * Formula Editor UI + * Handles the user interface components for the Formula Editor + */ + +import { + generateUniqueId, + addClass, + removeClass, + toggleClass, + debounce, + fieldToLabel, + formatErrorMessage, + showToast +} from './utils'; + +class FormulaEditorUI { + /** + * Create a new FormulaEditorUI instance + * @param {Object} options - Configuration options + * @param {FormulaEditor} options.editor - Reference to the main FormulaEditor instance + * @param {HTMLElement} options.container - Container element for the UI + * @param {boolean} [options.showFieldsPanel=true] - Whether to show the fields panel + * @param {boolean} [options.showFunctionsPanel=true] - Whether to show the functions panel + * @param {boolean} [options.readOnly=false] - Whether the editor is read-only + * @param {string} [options.height='300px'] - Height of the editor + */ + constructor(options) { + this.options = Object.assign({ + editor: null, + container: null, + showFieldsPanel: true, + showFunctionsPanel: true, + readOnly: false, + height: '300px', + mode: 'formula' // 'formula', 'trigger', 'report' + }, options); + + // Validate required options + if (!this.options.editor) { + throw new Error(__('Editor instance is required for FormulaEditorUI')); + } + + if (!this.options.container) { + throw new Error(__('Container element is required for FormulaEditorUI')); + } + + // Initialize properties + this.editor = this.options.editor; + this.container = this.options.container; + this.uiId = generateUniqueId('formula-ui'); + this.editorElement = null; + this.fieldsPanelElement = null; + this.functionsPanelElement = null; + this.toolbarElement = null; + this.errorElement = null; + this.aceEditor = null; + this.debouncedResize = debounce(this.handleResize.bind(this), 100); + + // Build the UI + this.build(); + } + + /** + * Build the UI components + * @private + */ + build() { + // Clear container + this.container.innerHTML = ''; + this.container.className = 'formula-editor-container'; + + if (this.options.mode === 'trigger') { + addClass(this.container, 'trigger-mode'); + } else if (this.options.mode === 'report') { + addClass(this.container, 'report-mode'); + } + + // Create main layout + this.createLayout(); + + // Create components + this.createToolbar(); + this.createEditor(); + this.createFieldsPanel(); + this.createFunctionsPanel(); + this.createErrorPanel(); + + // Set initial state + this.setReadOnly(this.options.readOnly); + + // Initialize Ace editor if available + this.initAceEditor(); + + // Bind events + this.bindEvents(); + } + + /** + * Create the main layout structure + * @private + */ + createLayout() { + const wrapper = document.createElement('div'); + wrapper.className = 'formula-editor-wrapper'; + + // Top section with toolbar + const topSection = document.createElement('div'); + topSection.className = 'formula-editor-top'; + + // Main section with editor and panels + const mainSection = document.createElement('div'); + mainSection.className = 'formula-editor-main'; + + // Editor section + const editorSection = document.createElement('div'); + editorSection.className = 'formula-editor-section formula-editor-code-section'; + + // Panels section + const panelsSection = document.createElement('div'); + panelsSection.className = 'formula-editor-section formula-editor-panels-section'; + + // Bottom section with error panel + const bottomSection = document.createElement('div'); + bottomSection.className = 'formula-editor-bottom'; + + // Assemble the structure + mainSection.appendChild(editorSection); + mainSection.appendChild(panelsSection); + + wrapper.appendChild(topSection); + wrapper.appendChild(mainSection); + wrapper.appendChild(bottomSection); + + this.container.appendChild(wrapper); + + // Store references + this.wrapper = wrapper; + this.topSection = topSection; + this.mainSection = mainSection; + this.editorSection = editorSection; + this.panelsSection = panelsSection; + this.bottomSection = bottomSection; + } + + /** + * Create the toolbar + * @private + */ + createToolbar() { + const toolbar = document.createElement('div'); + toolbar.className = 'formula-editor-toolbar'; + + // Add buttons + + // Validate button + const validateBtn = this.createToolbarButton('validate', __('Validate'), 'small btn-default'); + validateBtn.innerHTML = ' ' + __('Validate'); + + // Help button + const helpBtn = this.createToolbarButton('help', __('Help'), 'small btn-default icon-btn'); + helpBtn.innerHTML = ''; + + // Settings button + const settingsBtn = this.createToolbarButton('settings', __('Settings'), 'small btn-default icon-btn'); + settingsBtn.innerHTML = ''; + + toolbar.appendChild(validateBtn); + toolbar.appendChild(helpBtn); + toolbar.appendChild(settingsBtn); + + // Add to top section + this.topSection.appendChild(toolbar); + this.toolbarElement = toolbar; + } + + /** + * Create a toolbar button + * @param {string} id - Button ID + * @param {string} title - Button title + * @param {string} className - Button CSS classes + * @returns {HTMLElement} Button element + * @private + */ + createToolbarButton(id, title, className = '') { + const button = document.createElement('button'); + button.type = 'button'; + button.dataset.action = id; + button.title = title; + button.className = `btn ${className}`; + + return button; + } + + /** + * Create the code editor area + * @private + */ + createEditor() { + const editorContainer = document.createElement('div'); + editorContainer.className = 'formula-editor-code-container'; + + // Create pre element for Ace editor + const editorElement = document.createElement('pre'); + editorElement.id = `${this.uiId}-code`; + editorElement.className = 'formula-editor-code'; + + // Fallback textarea (used if Ace is not available) + const fallbackTextarea = document.createElement('textarea'); + fallbackTextarea.className = 'formula-editor-textarea'; + fallbackTextarea.placeholder = __('Enter your formula here...'); + + editorContainer.appendChild(editorElement); + editorContainer.appendChild(fallbackTextarea); + + this.editorSection.appendChild(editorContainer); + + this.editorElement = editorElement; + this.fallbackTextarea = fallbackTextarea; + this.editorContainer = editorContainer; + + // Set height + editorContainer.style.height = this.options.height; + } + + /** + * Create the fields panel + * @private + */ + createFieldsPanel() { + if (!this.options.showFieldsPanel) return; + + const fieldsPanel = document.createElement('div'); + fieldsPanel.className = 'formula-editor-panel formula-editor-fields-panel'; + + // Panel header + const header = document.createElement('div'); + header.className = 'panel-header'; + header.innerHTML = `
${__('Available Fields')}
`; + + // Search input + const searchContainer = document.createElement('div'); + searchContainer.className = 'panel-search'; + + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.className = 'form-control'; + searchInput.placeholder = __('Search fields...'); + + searchContainer.appendChild(searchInput); + + // Fields list + const fieldsList = document.createElement('div'); + fieldsList.className = 'panel-content fields-list'; + + // Assemble the panel + fieldsPanel.appendChild(header); + fieldsPanel.appendChild(searchContainer); + fieldsPanel.appendChild(fieldsList); + + this.panelsSection.appendChild(fieldsPanel); + + // Store references + this.fieldsPanelElement = fieldsPanel; + this.fieldsListElement = fieldsList; + this.fieldsSearchInput = searchInput; + + // Populate fields + this.populateFields(); + } + + /** + * Create the functions panel + * @private + */ + createFunctionsPanel() { + if (!this.options.showFunctionsPanel) return; + + const functionsPanel = document.createElement('div'); + functionsPanel.className = 'formula-editor-panel formula-editor-functions-panel'; + + // Panel header + const header = document.createElement('div'); + header.className = 'panel-header'; + header.innerHTML = `
${__('Functions')}
`; + + // Search input + const searchContainer = document.createElement('div'); + searchContainer.className = 'panel-search'; + + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.className = 'form-control'; + searchInput.placeholder = __('Search functions...'); + + searchContainer.appendChild(searchInput); + + // Functions categories + const categoriesList = document.createElement('div'); + categoriesList.className = 'panel-content categories-list'; + + // Functions list + const functionsList = document.createElement('div'); + functionsList.className = 'panel-content functions-list'; + + // Assemble the panel + functionsPanel.appendChild(header); + functionsPanel.appendChild(searchContainer); + functionsPanel.appendChild(categoriesList); + functionsPanel.appendChild(functionsList); + + this.panelsSection.appendChild(functionsPanel); + + // Store references + this.functionsPanelElement = functionsPanel; + this.functionsListElement = functionsList; + this.categoriesListElement = categoriesList; + this.functionsSearchInput = searchInput; + + // Populate functions + this.populateFunctionCategories(); + } + + /** + * Create the error panel + * @private + */ + createErrorPanel() { + const errorPanel = document.createElement('div'); + errorPanel.className = 'formula-editor-error-panel hidden'; + + const errorIcon = document.createElement('i'); + errorIcon.className = 'fa fa-exclamation-triangle'; + + const errorMessage = document.createElement('span'); + errorMessage.className = 'error-message'; + + errorPanel.appendChild(errorIcon); + errorPanel.appendChild(errorMessage); + + this.bottomSection.appendChild(errorPanel); + + this.errorElement = errorPanel; + this.errorMessageElement = errorMessage; + } + + /** + * Initialize Ace editor if available + * @private + */ + initAceEditor() { + // Check if Ace is available + if (typeof ace === 'undefined') { + console.warn('Ace editor not available, using fallback textarea'); + addClass(this.editorElement, 'hidden'); + removeClass(this.fallbackTextarea, 'hidden'); + return; + } + + // Hide fallback textarea + addClass(this.fallbackTextarea, 'hidden'); + removeClass(this.editorElement, 'hidden'); + + // Initialize Ace editor + try { + this.aceEditor = ace.edit(this.editorElement.id); + this.aceEditor.setTheme('ace/theme/chrome'); + this.aceEditor.session.setMode('ace/mode/javascript'); + this.aceEditor.setShowPrintMargin(false); + this.aceEditor.setHighlightActiveLine(true); + this.aceEditor.setFontSize('12px'); + + // Set editor options + this.aceEditor.setOptions({ + enableBasicAutocompletion: true, + enableSnippets: true, + enableLiveAutocompletion: true + }); + + // Set initial value if available + const formula = this.editor.getValue(); + if (formula) { + this.aceEditor.setValue(formula, -1); + } + + // Bind editor events + this.aceEditor.on('change', () => { + const value = this.aceEditor.getValue(); + // Update the formula in the main editor + this.editor.setValue(value); + }); + + } catch (error) { + console.error('Error initializing Ace editor:', error); + // Fall back to textarea + addClass(this.editorElement, 'hidden'); + removeClass(this.fallbackTextarea, 'hidden'); + } + } + + /** + * Populate the fields panel with available fields + * @private + */ + populateFields() { + if (!this.fieldsListElement) return; + + // Clear current fields + this.fieldsListElement.innerHTML = ''; + + // Get fields from the editor + const fields = this.editor.fields || []; + + if (fields.length === 0) { + const noFields = document.createElement('div'); + noFields.className = 'empty-state'; + noFields.textContent = __('No fields available'); + this.fieldsListElement.appendChild(noFields); + return; + } + + // Create field groups + const fieldGroups = this.groupFields(fields); + + // Create group elements + for (const [group, groupFields] of Object.entries(fieldGroups)) { + const groupElement = this.createFieldGroup(group, groupFields); + this.fieldsListElement.appendChild(groupElement); + } + } + + /** + * Group fields by type/category + * @param {Array} fields - Fields to group + * @returns {Object} Grouped fields + * @private + */ + groupFields(fields) { + const groups = { + 'Standard': [], + 'Link & Select': [], + 'Text': [], + 'Numeric': [], + 'Date & Time': [], + 'Checkbox': [], + 'Tables': [], + 'Others': [] + }; + + // Group fields by type + for (const field of fields) { + if (field.standard) { + groups['Standard'].push(field); + continue; + } + + switch (field.type) { + case 'Link': + case 'Select': + case 'Dynamic Link': + groups['Link & Select'].push(field); + break; + case 'Data': + case 'Small Text': + case 'Long Text': + case 'Text Editor': + case 'Code': + groups['Text'].push(field); + break; + case 'Int': + case 'Float': + case 'Currency': + case 'Percent': + groups['Numeric'].push(field); + break; + case 'Date': + case 'Datetime': + case 'Time': + groups['Date & Time'].push(field); + break; + case 'Check': + groups['Checkbox'].push(field); + break; + case 'Table': + groups['Tables'].push(field); + break; + default: + groups['Others'].push(field); + } + } + + // Remove empty groups + for (const group in groups) { + if (groups[group].length === 0) { + delete groups[group]; + } + } + + return groups; + } + + /** + * Create a field group element + * @param {string} groupName - Group name + * @param {Array} fields - Fields in the group + * @returns {HTMLElement} Group element + * @private + */ + createFieldGroup(groupName, fields) { + const groupElement = document.createElement('div'); + groupElement.className = 'field-group'; + + // Group header + const header = document.createElement('div'); + header.className = 'group-header'; + header.innerHTML = ` + ${groupName} + ${fields.length} + + `; + + // Group content + const content = document.createElement('div'); + content.className = 'group-content'; + + // Add fields to the group + for (const field of fields) { + const fieldElement = this.createFieldElement(field); + content.appendChild(fieldElement); + } + + groupElement.appendChild(header); + groupElement.appendChild(content); + + // Toggle group on click + header.addEventListener('click', () => { + toggleClass(groupElement, 'collapsed'); + + // Toggle icon + const icon = header.querySelector('i'); + if (icon) { + icon.className = groupElement.classList.contains('collapsed') + ? 'fa fa-chevron-right' + : 'fa fa-chevron-down'; + } + }); + + return groupElement; + } + + /** + * Create a field element + * @param {Object} field - Field data + * @returns {HTMLElement} Field element + * @private + */ + createFieldElement(field) { + const fieldElement = document.createElement('div'); + fieldElement.className = 'field-item'; + fieldElement.dataset.fieldname = field.fieldname; + fieldElement.dataset.fieldtype = field.type; + + // Field icon based on type + let iconClass = 'fa-circle-o'; + + switch (field.type) { + case 'Link': + case 'Dynamic Link': + iconClass = 'fa-link'; + break; + case 'Select': + iconClass = 'fa-list'; + break; + case 'Table': + iconClass = 'fa-table'; + break; + case 'Int': + case 'Float': + case 'Currency': + case 'Percent': + iconClass = 'fa-calculator'; + break; + case 'Date': + case 'Datetime': + iconClass = 'fa-calendar'; + break; + case 'Check': + iconClass = 'fa-check-square-o'; + break; + case 'Data': + case 'Small Text': + case 'Long Text': + case 'Text Editor': + iconClass = 'fa-font'; + break; + case 'Code': + iconClass = 'fa-code'; + break; + case 'Object': + iconClass = 'fa-cubes'; + break; + } + + fieldElement.innerHTML = ` + + ${field.label || fieldToLabel(field.fieldname)} + ${field.type} + `; + + // Make field draggable + fieldElement.draggable = true; + fieldElement.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('text/plain', field.fieldname); + e.dataTransfer.effectAllowed = 'copy'; + }); + + // Double click to insert field + fieldElement.addEventListener('dblclick', () => { + this.insertField(field); + }); + + return fieldElement; + } + + /** + * Populate function categories + * @private + */ + populateFunctionCategories() { + if (!this.categoriesListElement) return; + + // Clear current categories + this.categoriesListElement.innerHTML = ''; + + // Function categories + const categories = this.getFunctionCategories(); + + // Create category elements + for (const category of categories) { + const categoryElement = document.createElement('div'); + categoryElement.className = 'category-item'; + categoryElement.dataset.category = category.id; + + categoryElement.innerHTML = ` + ${category.name} + `; + + // Select category on click + categoryElement.addEventListener('click', () => { + // Deselect all categories + const items = this.categoriesListElement.querySelectorAll('.category-item'); + items.forEach(item => removeClass(item, 'selected')); + + // Select this category + addClass(categoryElement, 'selected'); + + // Show functions for this category + this.populateFunctions(category.id); + }); + + this.categoriesListElement.appendChild(categoryElement); + } + + // Select first category by default + if (categories.length > 0) { + const firstCategory = this.categoriesListElement.querySelector('.category-item'); + if (firstCategory) { + firstCategory.click(); + } + } + } + + /** + * Get function categories + * @returns {Array} Function categories + * @private + */ + getFunctionCategories() { + return [ + { id: 'math', name: __('Math') }, + { id: 'text', name: __('Text') }, + { id: 'date', name: __('Date & Time') }, + { id: 'logical', name: __('Logical') }, + { id: 'array', name: __('Array & Table') }, + { id: 'financial', name: __('Financial') }, + { id: 'conversion', name: __('Conversion') }, + { id: 'utility', name: __('Utility') } + ]; + } + + /** + * Populate functions for a category + * @param {string} categoryId - Category ID + * @private + */ + populateFunctions(categoryId) { + if (!this.functionsListElement) return; + + // Clear current functions + this.functionsListElement.innerHTML = ''; + + // Get functions for the category + const functions = this.getFunctionsForCategory(categoryId); + + if (functions.length === 0) { + const noFunctions = document.createElement('div'); + noFunctions.className = 'empty-state'; + noFunctions.textContent = __('No functions available'); + this.functionsListElement.appendChild(noFunctions); + return; + } + + // Create function elements + for (const func of functions) { + const funcElement = this.createFunctionElement(func); + this.functionsListElement.appendChild(funcElement); + } + } + + /** + * Get functions for a category + * @param {string} categoryId - Category ID + * @returns {Array} Functions for the category + * @private + */ + getFunctionsForCategory(categoryId) { + // Definition of available functions by category + const functionsByCategory = { + 'math': [ + { name: 'abs', syntax: 'abs(number)', description: __('Returns the absolute value of a number') }, + { name: 'round', syntax: 'round(number, precision)', description: __('Rounds a number to a specified precision') }, + { name: 'floor', syntax: 'floor(number)', description: __('Rounds a number down to the nearest integer') }, + { name: 'ceil', syntax: 'ceil(number)', description: __('Rounds a number up to the nearest integer') }, + { name: 'min', syntax: 'min(number1, number2, ...)', description: __('Returns the smallest number in a set of numbers') }, + { name: 'max', syntax: 'max(number1, number2, ...)', description: __('Returns the largest number in a set of numbers') }, + { name: 'pow', syntax: 'pow(base, exponent)', description: __('Returns a number raised to a power') }, + { name: 'sqrt', syntax: 'sqrt(number)', description: __('Returns the square root of a number') } + ], + 'text': [ + { name: 'concat', syntax: 'concat(text1, text2, ...)', description: __('Joins multiple strings together') }, + { name: 'includes', syntax: 'text.includes(search)', description: __('Checks if a string contains another string') }, + { name: 'indexOf', syntax: 'text.indexOf(search)', description: __('Returns the position of a substring in a string') }, + { name: 'toLowerCase', syntax: 'text.toLowerCase()', description: __('Converts a string to lowercase') }, + { name: 'toUpperCase', syntax: 'text.toUpperCase()', description: __('Converts a string to uppercase') }, + { name: 'trim', syntax: 'text.trim()', description: __('Removes whitespace from both ends of a string') }, + { name: 'replace', syntax: 'text.replace(search, replacement)', description: __('Replaces occurrences of a substring with another string') }, + { name: 'substring', syntax: 'text.substring(start, end)', description: __('Extracts a portion of a string') } + ], + 'date': [ + { name: 'now', syntax: 'frappe.datetime.now()', description: __('Returns the current date and time') }, + { name: 'today', syntax: 'frappe.datetime.get_today()', description: __('Returns the current date') }, + { name: 'addDays', syntax: 'frappe.datetime.add_days(date, days)', description: __('Adds a specified number of days to a date') }, + { name: 'addMonths', syntax: 'frappe.datetime.add_months(date, months)', description: __('Adds a specified number of months to a date') }, + { name: 'formatDate', syntax: 'frappe.datetime.str_to_user(date)', description: __('Formats a date according to user settings') }, + { name: 'getDateDiff', syntax: 'frappe.datetime.get_diff(date1, date2)', description: __('Returns the difference in days between two dates') } + ], + 'logical': [ + { name: 'if', syntax: 'condition ? trueValue : falseValue', description: __('Returns one value if a condition is true and another if it is false') }, + { name: 'and', syntax: 'value1 && value2', description: __('Returns true if both operands are true') }, + { name: 'or', syntax: 'value1 || value2', description: __('Returns true if either operand is true') }, + { name: 'not', syntax: '!value', description: __('Returns the opposite of a boolean value') } + ], + 'array': [ + { name: 'length', syntax: 'array.length', description: __('Returns the number of elements in an array') }, + { name: 'map', syntax: 'array.map(function)', description: __('Creates a new array with the results of calling a function on every element') }, + { name: 'filter', syntax: 'array.filter(function)', description: __('Creates a new array with elements that pass a test') }, + { name: 'find', syntax: 'array.find(function)', description: __('Returns the first element that passes a test') }, + { name: 'reduce', syntax: 'array.reduce(function, initialValue)', description: __('Reduces an array to a single value') }, + { name: 'forEach', syntax: 'array.forEach(function)', description: __('Calls a function for each element in an array') } + ], + 'financial': [ + { name: 'flt', syntax: 'flt(value, precision)', description: __('Converts a value to a float with specified precision') }, + { name: 'fmt_money', syntax: 'format_currency(value, currency)', description: __('Formats a number as currency') }, + { name: 'cint', syntax: 'cint(value)', description: __('Converts a value to an integer') } + ], + 'conversion': [ + { name: 'parseInt', syntax: 'parseInt(string, radix)', description: __('Parses a string and returns an integer') }, + { name: 'parseFloat', syntax: 'parseFloat(string)', description: __('Parses a string and returns a floating point number') }, + { name: 'toString', syntax: 'value.toString()', description: __('Converts a value to a string') }, + { name: 'Number', syntax: 'Number(value)', description: __('Converts a value to a number') }, + { name: 'Boolean', syntax: 'Boolean(value)', description: __('Converts a value to a boolean') } + ], + 'utility': [ + { name: 'isNaN', syntax: 'isNaN(value)', description: __('Determines whether a value is NaN') }, + { name: 'isFinite', syntax: 'isFinite(value)', description: __('Determines whether a value is a finite number') }, + { name: 'typeof', syntax: 'typeof value', description: __('Returns the type of a value') }, + { name: 'JSON.stringify', syntax: 'JSON.stringify(value)', description: __('Converts a JavaScript object to a JSON string') }, + { name: 'JSON.parse', syntax: 'JSON.parse(string)', description: __('Parses a JSON string') } + ] + }; + + return functionsByCategory[categoryId] || []; + } + + /** + * Create a function element + * @param {Object} func - Function data + * @returns {HTMLElement} Function element + * @private + */ + createFunctionElement(func) { + const funcElement = document.createElement('div'); + funcElement.className = 'function-item'; + funcElement.dataset.function = func.name; + + funcElement.innerHTML = ` +
+ ${func.name} + ${func.syntax} +
+
${func.description}
+ `; + + // Insert function on double click + funcElement.addEventListener('dblclick', () => { + this.insertFunction(func); + }); + + return funcElement; + } + + /** + * Insert a field into the editor + * @param {Object} field - Field to insert + * @private + */ + insertField(field) { + if (!field || !field.fieldname) return; + + let insertText = field.fieldname; + + // For child table fields, include the table name + if (field.parent_table) { + insertText = `${field.parent_table}.${field.fieldname}`; + } + + this.insertAtCursor(insertText); + } + + /** + * Insert a function into the editor + * @param {Object} func - Function to insert + * @private + */ + insertFunction(func) { + if (!func || !func.name) return; + + // Extract the function name and parameters + const match = func.syntax.match(/(\w+)\((.*)\)/); + + if (match) { + const functionName = match[1]; + const parameters = match[2].split(',').map(p => p.trim()); + + // Create function text with cursor positioned between parentheses + let insertText = functionName + '('; + + if (parameters.length === 1 && parameters[0] === '') { + // No parameters + insertText += ')'; + } else { + // Add parameters as placeholders + insertText += parameters.join(', ') + ')'; + } + + this.insertAtCursor(insertText); + } else { + // Just insert the function name if syntax parsing fails + this.insertAtCursor(func.name); + } + } + + /** + * Insert text at the cursor position + * @param {string} text - Text to insert + * @private + */ + insertAtCursor(text) { + if (this.aceEditor) { + // Insert at Ace editor cursor + this.aceEditor.insert(text); + this.aceEditor.focus(); + } else { + // Insert at textarea cursor + const textarea = this.fallbackTextarea; + const startPos = textarea.selectionStart; + const endPos = textarea.selectionEnd; + const scrollTop = textarea.scrollTop; + + textarea.value = textarea.value.substring(0, startPos) + text + textarea.value.substring(endPos); + + // Move cursor after the inserted text + textarea.selectionStart = startPos + text.length; + textarea.selectionEnd = startPos + text.length; + + // Preserve scroll position + textarea.scrollTop = scrollTop; + + // Update the editor value + const event = new Event('input', { bubbles: true }); + textarea.dispatchEvent(event); + + textarea.focus(); + } + } + + /** + * Set editor value + * @param {string} value - New value + * @public + */ + setValue(value) { + if (this.aceEditor) { + this.aceEditor.setValue(value || '', -1); + } else if (this.fallbackTextarea) { + this.fallbackTextarea.value = value || ''; + } + } + + /** + * Get editor value + * @returns {string} Editor value + * @public + */ + getValue() { + if (this.aceEditor) { + return this.aceEditor.getValue(); + } else if (this.fallbackTextarea) { + return this.fallbackTextarea.value; + } + + return ''; + } + + /** + * Set editor to read-only mode + * @param {boolean} readOnly - Whether editor is read-only + * @public + */ + setReadOnly(readOnly) { + if (this.aceEditor) { + this.aceEditor.setReadOnly(readOnly); + } else if (this.fallbackTextarea) { + this.fallbackTextarea.readOnly = readOnly; + } + + toggleClass(this.container, 'read-only', readOnly); + } + + /** + * Show error message + * @param {string} message - Error message + * @public + */ + showError(message) { + if (!this.errorElement || !this.errorMessageElement) return; + + this.errorMessageElement.textContent = formatErrorMessage(message); + removeClass(this.errorElement, 'hidden'); + addClass(this.container, 'has-error'); + } + + /** + * Hide error message + * @public + */ + hideError() { + if (!this.errorElement) return; + + addClass(this.errorElement, 'hidden'); + removeClass(this.container, 'has-error'); + } + + /** + * Bind UI events + * @private + */ + bindEvents() { + // Window resize event + window.addEventListener('resize', this.debouncedResize); + + // Toolbar button events + if (this.toolbarElement) { + this.toolbarElement.addEventListener('click', (event) => { + const button = event.target.closest('button'); + if (!button) return; + + const action = button.dataset.action; + if (!action) return; + + // Handle button actions + switch (action) { + case 'validate': + this.validateFormula(); + break; + case 'help': + this.showHelp(); + break; + case 'settings': + this.showSettings(); + break; + } + }); + } + + // Fields search event + if (this.fieldsSearchInput) { + this.fieldsSearchInput.addEventListener('input', () => { + this.searchFields(this.fieldsSearchInput.value); + }); + } + + // Functions search event + if (this.functionsSearchInput) { + this.functionsSearchInput.addEventListener('input', () => { + this.searchFunctions(this.functionsSearchInput.value); + }); + } + + // Fallback textarea events (if Ace is not available) + if (this.fallbackTextarea) { + this.fallbackTextarea.addEventListener('input', () => { + // Update the main editor value + const value = this.fallbackTextarea.value; + this.editor.setValue(value); + }); + } + } + + /** + * Handle window resize + * @private + */ + handleResize() { + if (this.aceEditor) { + this.aceEditor.resize(); + } + } + + /** + * Validate the current formula + * @private + */ + validateFormula() { + const formula = this.getValue(); + const isValid = this.editor.validate(); + + if (isValid) { + showToast(__('Formula is valid'), 'success'); + } + } + + /** + * Show help dialog + * @private + */ + showHelp() { + frappe.help.show_video( + 'https://www.youtube.com/embed/exampleVideo', // Placeholder URL + __('Formula Editor Help') + ); + } + + /** + * Show settings dialog + * @private + */ + showSettings() { + // Implementation will vary based on requirements + console.log('Show settings dialog'); + } + + /** + * Search fields + * @param {string} query - Search query + * @private + */ + searchFields(query) { + if (!query) { + // Show all fields + removeClass(this.fieldsListElement.querySelectorAll('.field-item'), 'hidden'); + removeClass(this.fieldsListElement.querySelectorAll('.field-group'), 'hidden'); + return; + } + + query = query.toLowerCase(); + + // Hide/show field items based on query + const fieldItems = this.fieldsListElement.querySelectorAll('.field-item'); + fieldItems.forEach(item => { + const fieldname = item.dataset.fieldname; + const label = item.querySelector('.field-label').textContent; + + if (fieldname.toLowerCase().includes(query) || label.toLowerCase().includes(query)) { + removeClass(item, 'hidden'); + } else { + addClass(item, 'hidden'); + } + }); + + // Hide empty groups + const fieldGroups = this.fieldsListElement.querySelectorAll('.field-group'); + fieldGroups.forEach(group => { + const visibleItems = group.querySelectorAll('.field-item:not(.hidden)'); + if (visibleItems.length === 0) { + addClass(group, 'hidden'); + } else { + removeClass(group, 'hidden'); + + // Expand groups with matching items + removeClass(group, 'collapsed'); + const icon = group.querySelector('.group-header i'); + if (icon) { + icon.className = 'fa fa-chevron-down'; + } + } + }); + } + + /** + * Search functions + * @param {string} query - Search query + * @private + */ + searchFunctions(query) { + if (!query) { + // Reset and show first category + const firstCategory = this.categoriesListElement.querySelector('.category-item'); + if (firstCategory) { + firstCategory.click(); + } + return; + } + + query = query.toLowerCase(); + + // Search in all categories + const allFunctions = []; + const categories = this.getFunctionCategories(); + + for (const category of categories) { + const functions = this.getFunctionsForCategory(category.id); + for (const func of functions) { + if (func.name.toLowerCase().includes(query) || + func.description.toLowerCase().includes(query)) { + // Add category info to the function + func.category = category.name; + allFunctions.push(func); + } + } + } + + // Clear functions list + this.functionsListElement.innerHTML = ''; + + // Show matching functions + if (allFunctions.length === 0) { + const noFunctions = document.createElement('div'); + noFunctions.className = 'empty-state'; + noFunctions.textContent = __('No matching functions found'); + this.functionsListElement.appendChild(noFunctions); + } else { + for (const func of allFunctions) { + const funcElement = this.createFunctionElement(func); + + // Add category badge + if (func.category) { + const badge = document.createElement('span'); + badge.className = 'category-badge'; + badge.textContent = func.category; + funcElement.querySelector('.function-header').appendChild(badge); + } + + this.functionsListElement.appendChild(funcElement); + } + } + + // Deselect all categories + const items = this.categoriesListElement.querySelectorAll('.category-item'); + items.forEach(item => removeClass(item, 'selected')); + } + + /** + * Destroy the UI and clean up + * @public + */ + destroy() { + // Remove window event listeners + window.removeEventListener('resize', this.debouncedResize); + + // Destroy Ace editor if exists + if (this.aceEditor) { + this.aceEditor.destroy(); + this.aceEditor = null; + } + + // Clear container + if (this.container) { + this.container.innerHTML = ''; + } + } +} + +export default FormulaEditorUI; \ No newline at end of file diff --git a/formula_editor/public/js/universal_fields.js b/formula_editor/public/js/universal_fields.js new file mode 100644 index 0000000..dc09d48 --- /dev/null +++ b/formula_editor/public/js/universal_fields.js @@ -0,0 +1,716 @@ +/** + * Formula Editor Universal Fields + * Handles universal field functionality for cross-doctype formulas + */ + +import { + generateUniqueId, + isEmpty, + deepClone, + formatErrorMessage, + showToast, + debounce +} from './utils'; + +class FormulaEditorUniversalFields { + /** + * Create a new FormulaEditorUniversalFields instance + * @param {Object} options - Configuration options + * @param {Object} options.editor - Reference to the main FormulaEditor instance + * @param {Array} [options.linkedDoctypes=[]] - Initially linked doctypes + * @param {Function} [options.onChange] - Callback for field changes + */ + constructor(options) { + this.options = Object.assign({ + editor: null, + linkedDoctypes: [], + onChange: null + }, options); + + // Validate required options + if (!this.options.editor) { + throw new Error(__('Editor instance is required for FormulaEditorUniversalFields')); + } + + // Initialize properties + this.editor = this.options.editor; + this.linkedDoctypes = deepClone(this.options.linkedDoctypes || []); + this.universalFields = []; + this.fieldCache = {}; + this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); + + // Initialize + this.init(); + } + + /** + * Initialize universal fields + * @private + */ + async init() { + // Ensure linked doctypes have proper structure + this.validateLinkedDoctypes(); + + // Load fields for linked doctypes + await this.loadUniversalFields(); + } + + /** + * Validate linked doctypes structure + * @private + */ + validateLinkedDoctypes() { + if (!Array.isArray(this.linkedDoctypes)) { + this.linkedDoctypes = []; + return; + } + + // Process each linked doctype to ensure it has all required properties + this.linkedDoctypes = this.linkedDoctypes.map(link => { + // Skip if not an object + if (typeof link !== 'object' || link === null) { + return null; + } + + // Ensure required properties + return { + id: link.id || generateUniqueId('link'), + label: link.label || link.doctype, + doctype: link.doctype, + linkField: link.linkField || null, + parentField: link.parentField || null, + fieldPrefix: link.fieldPrefix || link.doctype.toLowerCase().replace(/ /g, '_'), + fields: Array.isArray(link.fields) ? link.fields : [] + }; + }).filter(Boolean); // Remove invalid links + } + + /** + * Load fields for all linked doctypes + * @returns {Promise} + * @private + */ + async loadUniversalFields() { + try { + this.universalFields = []; + + // First load the main doctype fields + const mainDoctype = this.editor.options.doctype; + const mainFields = await this.getDocTypeFields(mainDoctype); + + // Add main doctype fields with doctype prefix (for clarity in formulas) + mainFields.forEach(field => { + this.universalFields.push({ + fieldname: `${mainDoctype.toLowerCase().replace(/ /g, '_')}.${field.fieldname}`, + original_fieldname: field.fieldname, + label: `${mainDoctype}: ${field.label || field.fieldname}`, + doctype: mainDoctype, + type: field.type, + options: field.options, + is_main_doctype: true + }); + }); + + // Load fields for each linked doctype + for (const link of this.linkedDoctypes) { + if (!link.doctype) continue; + + let fields = []; + + // If specific fields are defined, use those + if (Array.isArray(link.fields) && link.fields.length > 0) { + fields = link.fields.map(f => { + if (typeof f === 'string') { + return { fieldname: f, label: f }; + } + return f; + }); + } else { + // Otherwise get all fields from the doctype + fields = await this.getDocTypeFields(link.doctype); + } + + // Add to universal fields with prefix + fields.forEach(field => { + this.universalFields.push({ + fieldname: `${link.fieldPrefix}.${field.fieldname}`, + original_fieldname: field.fieldname, + label: `${link.label || link.doctype}: ${field.label || field.fieldname}`, + doctype: link.doctype, + type: field.type, + options: field.options, + linkInfo: { + linkField: link.linkField, + parentField: link.parentField, + fieldPrefix: link.fieldPrefix + }, + is_main_doctype: false + }); + }); + } + + // Notify about change + this.debouncedOnChange(); + + } catch (error) { + console.error('Error loading universal fields:', error); + showToast(__('Error loading universal fields'), 'error'); + } + } + + /** + * Get fields for a doctype + * @param {string} doctype - DocType name + * @returns {Promise} List of fields + * @private + */ + async getDocTypeFields(doctype) { + try { + // Check cache first + if (this.fieldCache[doctype]) { + return deepClone(this.fieldCache[doctype]); + } + + const fields = []; + let meta; + + // Try to get meta from cache + meta = frappe.get_meta(doctype); + + if (!meta) { + // If not in cache, fetch it + meta = await frappe.model.with_doctype(doctype); + } + + if (!meta || !meta.fields) { + throw new Error(__('Could not fetch metadata for {0}', [doctype])); + } + + // Add fields from the DocType + for (const field of meta.fields) { + // Skip certain field types + if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { + continue; + } + + fields.push({ + fieldname: field.fieldname, + label: field.label || field.fieldname, + type: field.fieldtype, + options: field.options, + is_child_table: field.fieldtype === 'Table' + }); + } + + // Cache the fields + this.fieldCache[doctype] = deepClone(fields); + + return fields; + + } catch (error) { + console.error(`Error getting fields for doctype ${doctype}:`, error); + return []; + } + } + + /** + * Get all universal fields + * @returns {Array} List of universal fields + * @public + */ + getUniversalFields() { + return deepClone(this.universalFields); + } + + /** + * Get linked doctypes + * @returns {Array} List of linked doctypes + * @public + */ + getLinkedDoctypes() { + return deepClone(this.linkedDoctypes); + } + + /** + * Add a linked doctype + * @param {Object} linkData - Linked doctype data + * @returns {string} New link ID + * @public + */ + async addLinkedDoctype(linkData = {}) { + if (!linkData.doctype) { + throw new Error(__('DocType is required for linked doctype')); + } + + const newLink = { + id: generateUniqueId('link'), + label: linkData.label || linkData.doctype, + doctype: linkData.doctype, + linkField: linkData.linkField || null, + parentField: linkData.parentField || null, + fieldPrefix: linkData.fieldPrefix || linkData.doctype.toLowerCase().replace(/ /g, '_'), + fields: Array.isArray(linkData.fields) ? deepClone(linkData.fields) : [] + }; + + this.linkedDoctypes.push(newLink); + + // Reload universal fields + await this.loadUniversalFields(); + + return newLink.id; + } + + /** + * Update a linked doctype + * @param {string} id - Link ID + * @param {Object} updates - Properties to update + * @returns {boolean} Success status + * @public + */ + async updateLinkedDoctype(id, updates) { + const index = this.linkedDoctypes.findIndex(l => l.id === id); + if (index === -1) return false; + + // Clone the link + const link = deepClone(this.linkedDoctypes[index]); + + // Apply updates + Object.keys(updates).forEach(key => { + // Handle special properties + if (key === 'fields' && Array.isArray(updates.fields)) { + link.fields = deepClone(updates.fields); + } else if (key !== 'id') { // Don't allow changing the ID + link[key] = updates[key]; + } + }); + + // Update the link + this.linkedDoctypes[index] = link; + + // Reload universal fields + await this.loadUniversalFields(); + + return true; + } + + /** + * Delete a linked doctype + * @param {string} id - Link ID + * @returns {boolean} Success status + * @public + */ + async deleteLinkedDoctype(id) { + const index = this.linkedDoctypes.findIndex(l => l.id === id); + if (index === -1) return false; + + this.linkedDoctypes.splice(index, 1); + + // Reload universal fields + await this.loadUniversalFields(); + + return true; + } + + /** + * Search for doctypes to link + * @param {string} query - Search query + * @param {number} [limit=20] - Maximum number of results + * @returns {Promise} Search results + * @public + */ + async searchDoctypes(query, limit = 20) { + try { + const doctypes = await frappe.db.get_list('DocType', { + filters: { + name: ['like', `%${query}%`], + istable: 0, + issingle: 0 + }, + fields: ['name', 'module'], + limit: limit + }); + + return doctypes.map(dt => ({ + value: dt.name, + label: dt.name, + description: dt.module + })); + + } catch (error) { + console.error('Error searching doctypes:', error); + return []; + } + } + + /** + * Get link fields for a doctype + * @param {string} doctype - DocType name + * @returns {Promise} List of link fields + * @public + */ + async getLinkFields(doctype) { + try { + const fields = await this.getDocTypeFields(doctype); + + // Filter to only link fields + return fields.filter(f => + f.type === 'Link' || + f.type === 'Dynamic Link' + ).map(f => ({ + value: f.fieldname, + label: f.label || f.fieldname, + fieldtype: f.type, + options: f.options + })); + + } catch (error) { + console.error(`Error getting link fields for ${doctype}:`, error); + return []; + } + } + + /** + * Find potential link fields between two doctypes + * @param {string} sourceDoctype - Source DocType name + * @param {string} targetDoctype - Target DocType name + * @returns {Promise} List of potential link fields + * @public + */ + async findPotentialLinks(sourceDoctype, targetDoctype) { + try { + const sourceFields = await this.getDocTypeFields(sourceDoctype); + const potentialLinks = []; + + // Find Link fields that point to the target doctype + sourceFields.forEach(field => { + if (field.type === 'Link' && field.options === targetDoctype) { + potentialLinks.push({ + fieldname: field.fieldname, + label: field.label || field.fieldname, + linkType: 'direct', + description: __('Direct link to {0}', [targetDoctype]) + }); + } + }); + + // Look for child tables that might link to the target + sourceFields.forEach(field => { + if (field.is_child_table) { + potentialLinks.push({ + fieldname: field.fieldname, + label: field.label || field.fieldname, + linkType: 'child_table', + description: __('Child table that might contain links to {0}', [targetDoctype]), + childDoctype: field.options + }); + } + }); + + // Also check target doctype for links back to source + const targetFields = await this.getDocTypeFields(targetDoctype); + + targetFields.forEach(field => { + if (field.type === 'Link' && field.options === sourceDoctype) { + potentialLinks.push({ + fieldname: field.fieldname, + label: field.label || field.fieldname, + linkType: 'reverse', + description: __('Reverse link from {0}', [targetDoctype]) + }); + } + }); + + return potentialLinks; + + } catch (error) { + console.error(`Error finding potential links between ${sourceDoctype} and ${targetDoctype}:`, error); + return []; + } + } + + /** + * Generate a formula to fetch data from a linked doctype + * @param {string} linkId - Link ID + * @param {string} fieldname - Field name to fetch + * @returns {string} Generated formula + * @public + */ + generateLinkFormula(linkId, fieldname) { + const link = this.linkedDoctypes.find(l => l.id === linkId); + if (!link) return ''; + + const targetField = this.universalFields.find(f => + f.doctype === link.doctype && + f.original_fieldname === fieldname + ); + + if (!targetField) return ''; + + // If it's a direct link (parent to child) + if (link.linkField) { + return ` +// Get linked ${link.doctype} based on ${link.linkField} +(function() { + const linked = frappe.db.get_value('${link.doctype}', { + name: doc.${link.linkField} + }, '${fieldname}'); + + return linked ? linked.message.${fieldname} : null; +})()`; + } + + // If it's a child table link + if (link.parentField) { + return ` +// Get linked ${link.doctype} records where parent field matches +(function() { + const linked = frappe.db.get_list('${link.doctype}', { + filters: { + ${link.parentField}: doc.name + }, + fields: ['${fieldname}'] + }); + + return linked ? linked.map(item => item.${fieldname}) : []; +})()`; + } + + // Generic case + return ` +// Fetch data from ${link.doctype} +frappe.db.get_value('${link.doctype}', null, '${fieldname}')`; + } + + /** + * Generate a helper function to work with linked doctypes + * @param {string} functionType - Type of function to generate (get_linked, set_linked, etc.) + * @returns {string} Generated function code + * @public + */ + generateHelperFunction(functionType) { + switch (functionType) { + case 'get_linked': + return ` +/** + * Get a value from a linked document + * @param {string} doctype - DocType of linked document + * @param {Object} filters - Filters to find the document + * @param {string} field - Field to retrieve + * @returns {*} Field value + */ +function get_linked(doctype, filters, field) { + try { + const result = frappe.db.get_value(doctype, filters, field); + return result && result.message ? result.message[field] : null; + } catch (error) { + console.error(\`Error getting linked value: \${error}\`); + return null; + } +}`; + + case 'get_linked_list': + return ` +/** + * Get values from linked documents + * @param {string} doctype - DocType of linked documents + * @param {Object} filters - Filters to find the documents + * @param {string|Array} fields - Fields to retrieve + * @returns {Array} List of documents/values + */ +function get_linked_list(doctype, filters, fields) { + try { + return frappe.db.get_list(doctype, { + filters: filters, + fields: fields + }); + } catch (error) { + console.error(\`Error getting linked list: \${error}\`); + return []; + } +}`; + + case 'set_linked': + return ` +/** + * Update a value in a linked document + * @param {string} doctype - DocType of linked document + * @param {string} name - Name of linked document + * @param {Object} values - Values to update + * @returns {boolean} Success status + */ +function set_linked(doctype, name, values) { + try { + frappe.db.set_value(doctype, name, values); + return true; + } catch (error) { + console.error(\`Error setting linked value: \${error}\`); + return false; + } +}`; + + default: + return ''; + } + } + + /** + * Get a preview of data from linked doctypes + * @param {Object} doc - Current document + * @returns {Promise} Preview data + * @public + */ + async getLinkedDataPreview(doc) { + if (!doc) return {}; + + const preview = {}; + + for (const link of this.linkedDoctypes) { + try { + preview[link.fieldPrefix] = await this.getLinkedDocTypeData(link, doc); + } catch (error) { + console.error(`Error getting preview for ${link.doctype}:`, error); + preview[link.fieldPrefix] = { error: formatErrorMessage(error) }; + } + } + + return preview; + } + + /** + * Get data for a linked doctype + * @param {Object} link - Link configuration + * @param {Object} doc - Current document + * @returns {Promise} Linked data + * @private + */ + async getLinkedDocTypeData(link, doc) { + // If it's a direct link (parent to child) + if (link.linkField && doc[link.linkField]) { + // Get single linked document + const result = await frappe.db.get_value( + link.doctype, + { name: doc[link.linkField] }, + '*' + ); + + return result && result.message ? result.message : null; + } + + // If it's a child table link (child to parent) + if (link.parentField) { + // Get multiple linked documents + const result = await frappe.db.get_list( + link.doctype, + { + filters: { + [link.parentField]: doc.name + }, + fields: ['*'], + limit: 10 + } + ); + + return result || []; + } + + // If no specific link field is defined, return empty + return null; + } + + /** + * Handle field changes + * @private + */ + handleChange() { + if (typeof this.options.onChange === 'function') { + this.options.onChange(this.universalFields); + } + + // Update editor fields to include universal fields + if (this.editor && typeof this.editor.setFields === 'function') { + // Convert universal fields to editor field format + const editorFields = this.universalFields.map(field => ({ + fieldname: field.fieldname, + label: field.label, + type: field.type, + options: field.options, + universal: true, + doctype: field.doctype + })); + + this.editor.setFields(editorFields); + } + } + + /** + * Import linked doctypes configuration from JSON + * @param {string|Object} json - JSON string or object + * @returns {Promise} Success status + * @public + */ + async importLinkedDoctypes(json) { + try { + let data; + + if (typeof json === 'string') { + data = JSON.parse(json); + } else if (typeof json === 'object' && json !== null) { + data = json; + } else { + throw new Error(__('Invalid import data format')); + } + + if (!Array.isArray(data)) { + throw new Error(__('Import data must be an array')); + } + + // Validate each linked doctype + const validLinks = data.filter(link => + typeof link === 'object' && + link !== null && + typeof link.doctype === 'string' + ); + + if (validLinks.length === 0) { + throw new Error(__('No valid linked doctypes found in import data')); + } + + // Replace linked doctypes + this.linkedDoctypes = validLinks.map(link => ({ + id: link.id || generateUniqueId('link'), + label: link.label || link.doctype, + doctype: link.doctype, + linkField: link.linkField || null, + parentField: link.parentField || null, + fieldPrefix: link.fieldPrefix || link.doctype.toLowerCase().replace(/ /g, '_'), + fields: Array.isArray(link.fields) ? link.fields : [] + })); + + // Reload universal fields + await this.loadUniversalFields(); + + return true; + + } catch (error) { + console.error('Error importing linked doctypes:', error); + return false; + } + } + + /** + * Export linked doctypes configuration to JSON + * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON + * @returns {string} JSON string + * @public + */ + exportLinkedDoctypes(prettyPrint = false) { + try { + return JSON.stringify(this.linkedDoctypes, null, prettyPrint ? 2 : 0); + } catch (error) { + console.error('Error exporting linked doctypes:', error); + return '[]'; + } + } +} + +export default FormulaEditorUniversalFields; \ No newline at end of file diff --git a/formula_editor/public/js/utils.js b/formula_editor/public/js/utils.js new file mode 100644 index 0000000..a461b09 --- /dev/null +++ b/formula_editor/public/js/utils.js @@ -0,0 +1,321 @@ +/** + * Formula Editor Utilities + * Contains helper functions used across the Formula Editor modules + */ + +import { __ } from 'frappe.utils'; + +/** + * Safely evaluates a formula/expression + * @param {string} formula - The formula to evaluate + * @param {Object} context - Context variables for the formula + * @returns {*} The result of the evaluation + */ +function safeEval(formula, context = {}) { + try { + // Create a safe function with the provided context + const contextKeys = Object.keys(context); + const contextValues = Object.values(context); + + // Using Function constructor with context variables as parameters + const evalFunction = new Function(...contextKeys, `"use strict"; return (${formula});`); + + return evalFunction(...contextValues); + } catch (error) { + console.error(`Error evaluating formula: ${formula}`, error); + return null; + } +} + +/** + * Validates if a formula is syntactically correct + * @param {string} formula - The formula to validate + * @returns {Object} Result with isValid and error message if any + */ +function validateFormula(formula) { + try { + // Simple validation by trying to parse as function + new Function(`"use strict"; return (${formula});`); + return { isValid: true, error: null }; + } catch (error) { + return { + isValid: false, + error: error.message.replace(/Function|new|constructor|[()]/g, '') + }; + } +} + +/** + * Escapes special characters in a string for use in regex + * @param {string} str - String to escape + * @returns {string} Escaped string + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Gets all variables/fields referenced in a formula + * @param {string} formula - The formula to analyze + * @param {Array} availableFields - List of available fields + * @returns {Array} Array of field names found in the formula + */ +function extractReferencedFields(formula, availableFields) { + if (!formula || !availableFields || !availableFields.length) { + return []; + } + + const referencedFields = []; + + // Sort fields by length (descending) to match longest field names first + const sortedFields = [...availableFields].sort((a, b) => b.length - a.length); + + for (const field of sortedFields) { + // Create a regex to find the field name (with word boundaries) + const fieldRegex = new RegExp(`\\b${escapeRegExp(field)}\\b`, 'g'); + if (fieldRegex.test(formula)) { + referencedFields.push(field); + } + } + + return referencedFields; +} + +/** + * Creates a unique ID for DOM elements + * @param {string} prefix - Prefix for the ID + * @returns {string} Unique ID + */ +function generateUniqueId(prefix = 'fe') { + return `${prefix}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Debounces a function call + * @param {Function} func - Function to debounce + * @param {number} wait - Delay in milliseconds + * @returns {Function} Debounced function + */ +function debounce(func, wait = 300) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +/** + * Throttles a function call + * @param {Function} func - Function to throttle + * @param {number} limit - Limit in milliseconds + * @returns {Function} Throttled function + */ +function throttle(func, limit = 300) { + let inThrottle; + return function executedFunction(...args) { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => { + inThrottle = false; + }, limit); + } + }; +} + +/** + * Formats error messages for display + * @param {Error|string} error - Error object or message + * @returns {string} Formatted error message + */ +function formatErrorMessage(error) { + if (!error) return ''; + + const errorMsg = typeof error === 'string' ? error : error.message || __('Unknown Error'); + return errorMsg.replace(/^Error:\s*/i, ''); +} + +/** + * Shows a toast notification + * @param {string} message - Message to display + * @param {string} type - Type of notification (success, error, warning) + */ +function showToast(message, type = 'error') { + frappe.show_alert({ + message: message, + indicator: type + }, 5); +} + +/** + * Converts a field name to a human-readable label + * @param {string} fieldname - Field name to convert + * @returns {string} Human readable field label + */ +function fieldToLabel(fieldname) { + if (!fieldname) return ''; + + // Replace underscores and dots with spaces + let label = fieldname.replace(/[_\.]/g, ' '); + + // Capitalize each word + label = label.replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()); + + return label; +} + +/** + * Adds a CSS class to an element if not already applied + * @param {HTMLElement} element - DOM element + * @param {string} className - CSS class to add + */ +function addClass(element, className) { + if (!element) return; + + if (!element.classList.contains(className)) { + element.classList.add(className); + } +} + +/** + * Removes a CSS class from an element + * @param {HTMLElement} element - DOM element + * @param {string} className - CSS class to remove + */ +function removeClass(element, className) { + if (!element) return; + + if (element.classList.contains(className)) { + element.classList.remove(className); + } +} + +/** + * Toggles a CSS class on an element + * @param {HTMLElement} element - DOM element + * @param {string} className - CSS class to toggle + * @param {boolean} condition - Optional condition to determine toggle + */ +function toggleClass(element, className, condition) { + if (!element) return; + + if (condition === undefined) { + element.classList.toggle(className); + } else { + condition ? addClass(element, className) : removeClass(element, className); + } +} + +/** + * Parses a string into a JSON object safely + * @param {string} str - String to parse + * @param {*} defaultValue - Default value if parsing fails + * @returns {*} Parsed JSON or default value + */ +function safeParseJSON(str, defaultValue = {}) { + try { + return JSON.parse(str); + } catch (e) { + return defaultValue; + } +} + +/** + * Checks if a variable is empty (null, undefined, empty string, empty array, empty object) + * @param {*} value - Value to check + * @returns {boolean} True if empty + */ +function isEmpty(value) { + if (value === null || value === undefined) return true; + if (typeof value === 'string' && value.trim() === '') return true; + if (Array.isArray(value) && value.length === 0) return true; + if (typeof value === 'object' && Object.keys(value).length === 0) return true; + + return false; +} + +/** + * Deep clones an object + * @param {*} obj - Object to clone + * @returns {*} Cloned object + */ +function deepClone(obj) { + if (obj === null || typeof obj !== 'object') return obj; + + try { + return JSON.parse(JSON.stringify(obj)); + } catch (e) { + // Fallback for circular references + const clone = Array.isArray(obj) ? [] : {}; + + Object.keys(obj).forEach(key => { + clone[key] = typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key]; + }); + + return clone; + } +} + +/** + * Gets a value from an object using a path string (e.g., "customer.address.city") + * @param {Object} obj - Object to get value from + * @param {string} path - Path to the value + * @param {*} defaultValue - Default value if path doesn't exist + * @returns {*} Value at path or default value + */ +function getValueByPath(obj, path, defaultValue = undefined) { + if (!obj || !path) return defaultValue; + + const keys = path.split('.'); + let current = obj; + + for (const key of keys) { + if (current === undefined || current === null) { + return defaultValue; + } + + current = current[key]; + } + + return current !== undefined ? current : defaultValue; +} + +/** + * Checks if a key or formula is a valid JavaScript identifier + * @param {string} key - Key to validate + * @returns {boolean} True if valid + */ +function isValidIdentifier(key) { + if (!key || typeof key !== 'string') return false; + + // JavaScript identifier regex (letters, numbers, underscore, dollar sign) + // Must start with letter, underscore or dollar sign + const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + + return validIdentifierRegex.test(key); +} + +export { + safeEval, + validateFormula, + escapeRegExp, + extractReferencedFields, + generateUniqueId, + debounce, + throttle, + formatErrorMessage, + showToast, + fieldToLabel, + addClass, + removeClass, + toggleClass, + safeParseJSON, + isEmpty, + deepClone, + getValueByPath, + isValidIdentifier +}; \ No newline at end of file