fixed bug with report field not having edit and delete button

This commit is contained in:
Ali 2025-04-18 16:45:04 +04:00
parent 30f878fc82
commit 8750e6b688
14 changed files with 7321 additions and 600 deletions

View File

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

View File

@ -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
},

View File

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

View File

@ -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 <head>
# ------------------
# 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 = []

View File

@ -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;
}

View File

@ -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;

View File

@ -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 = $('<div class="formula-editor-field-wrapper"></div>');
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 = $(`
<button class="btn btn-xs btn-default formula-editor-btn" title="${__('Edit Formula')}">
<i class="fa fa-calculator"></i>
</button>
`);
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;

File diff suppressed because it is too large Load Diff

View File

@ -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<Array>} 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<Object>} 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<boolean>} 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<void>}
* @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<void>}
* @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;

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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<void>}
* @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<Array>} 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<Array>} 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<Array>} 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<Array>} 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<Object>} 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<Object|Array>} 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<boolean>} 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;

View File

@ -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
};