diff --git a/formula_editor/public/css/formula_editor.css b/formula_editor/public/css/formula_editor.css deleted file mode 100644 index e3093a0..0000000 --- a/formula_editor/public/css/formula_editor.css +++ /dev/null @@ -1,359 +0,0 @@ -/** - * Formula Editor Styles - */ - - .formula-editor-container { - position: relative; - border: 1px solid var(--border-color); - border-radius: 4px; - min-height: 100px; - display: flex; - flex-direction: column; - } - - .formula-editor-container.has-error { - border-color: var(--red-500); - } - - .formula-editor-wrapper { - display: flex; - flex-direction: column; - height: 100%; - } - - .formula-editor-top { - border-bottom: 1px solid var(--border-color); - padding: 4px; - display: flex; - justify-content: space-between; - align-items: center; - } - - .formula-editor-main { - display: flex; - flex: 1; - min-height: 200px; - } - - .formula-editor-section { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - } - - .formula-editor-code-section { - flex: 2; - border-right: 1px solid var(--border-color); - } - - .formula-editor-panels-section { - flex: 1; - min-width: 250px; - max-width: 300px; - } - - .formula-editor-bottom { - border-top: 1px solid var(--border-color); - padding: 4px 8px; - display: flex; - align-items: center; - } - - .formula-editor-toolbar { - display: flex; - align-items: center; - gap: 4px; - } - - .formula-editor-code-container { - flex: 1; - position: relative; - overflow: hidden; - } - - .formula-editor-code { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - font-family: monospace; - font-size: 13px; - height: 100%; - width: 100%; - } - - .formula-editor-textarea { - width: 100%; - height: 100%; - resize: none; - border: none; - padding: 8px; - font-family: monospace; - font-size: 13px; - } - - .formula-editor-panel { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - } - - .panel-header { - padding: 8px; - font-weight: bold; - border-bottom: 1px solid var(--border-color); - } - - .panel-search { - padding: 4px 8px; - border-bottom: 1px solid var(--border-color); - } - - .panel-content { - padding: 0; - overflow-y: auto; - flex: 1; - } - - .field-group { - margin-bottom: 2px; - } - - .group-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 8px; - cursor: pointer; - background-color: var(--bg-light-gray); - } - - .group-header:hover { - background-color: var(--bg-gray); - } - - .group-name { - font-weight: 500; - } - - .group-count { - font-size: 0.8em; - color: var(--text-muted); - margin-right: 4px; - } - - .group-content { - padding: 4px 0; - } - - .collapsed .group-content { - display: none; - } - - .field-item { - display: flex; - align-items: center; - padding: 4px 8px 4px 16px; - cursor: pointer; - border-left: 2px solid transparent; - } - - .field-item:hover { - background-color: var(--bg-light-gray); - border-left-color: var(--primary); - } - - .field-icon { - margin-right: 8px; - color: var(--text-muted); - width: 16px; - text-align: center; - } - - .field-label { - flex: 1; - margin-right: 4px; - } - - .field-type { - font-size: 0.8em; - color: var(--text-muted); - } - - .category-item { - padding: 6px 12px; - cursor: pointer; - border-left: 2px solid transparent; - } - - .category-item:hover, - .category-item.selected { - background-color: var(--bg-light-gray); - } - - .category-item.selected { - border-left-color: var(--primary); - font-weight: 500; - } - - .function-item { - padding: 8px; - border-bottom: 1px solid var(--border-color); - cursor: pointer; - } - - .function-item:hover { - background-color: var(--bg-light-gray); - } - - .function-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 4px; - } - - .function-name { - font-weight: 500; - margin-right: 8px; - } - - .function-syntax { - font-family: monospace; - font-size: 0.9em; - color: var(--text-muted); - } - - .function-description { - font-size: 0.9em; - color: var(--text-muted); - } - - .formula-editor-error-panel { - color: var(--red-500); - display: flex; - align-items: center; - width: 100%; - } - - .formula-editor-error-panel i { - margin-right: 8px; - } - - .formula-editor-error-panel.hidden { - display: none; - } - - .formula-editor-btn { - margin-left: 4px; - } - - .formula-list-container { - padding: 8px; - } - - .formula-list-item { - border: 1px solid var(--border-color); - border-radius: 4px; - margin-bottom: 8px; - padding: 8px; - } - - .formula-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; - } - - .formula-name { - font-weight: 500; - margin-right: 8px; - } - - .formula-field { - font-size: 0.9em; - color: var(--text-muted); - } - - .formula-actions { - display: flex; - gap: 4px; - } - - .formula-code { - padding: 8px; - background-color: var(--bg-light-gray); - border-radius: 4px; - font-family: monospace; - font-size: 0.9em; - margin-bottom: 8px; - white-space: pre-wrap; - } - - .formula-description { - font-size: 0.9em; - color: var(--text-muted); - } - - .formula-toggle-btn.disabled { - opacity: 0.5; - } - - .formula-calculated-field { - background-color: rgba(var(--primary-rgb), 0.1); - position: relative; - } - - .formula-calculated-field::after { - content: "ƒ"; - position: absolute; - top: 0; - right: 4px; - font-size: 12px; - color: var(--primary); - font-weight: bold; - } - - .empty-state { - padding: 16px; - text-align: center; - color: var(--text-muted); - } - - .formula-editor-container.read-only .formula-editor-code, - .formula-editor-container.read-only .formula-editor-textarea { - background-color: var(--bg-light-gray); - cursor: not-allowed; - } - - .formula-editor-report-btn { - margin-left: 8px; - } - - .category-badge { - background-color: var(--bg-light-gray); - font-size: 0.8em; - padding: 2px 4px; - border-radius: 4px; - margin-left: auto; - } - - /* For trigger mode */ - .formula-editor-container.trigger-mode .formula-editor-panels-section { - flex: 1.5; - max-width: 400px; - } - - /* For report mode */ - .formula-editor-container.report-mode .formula-editor-code-section { - flex: 1.5; - } - - /* Hidden elements */ - .hidden { - display: none !important; - } \ No newline at end of file diff --git a/formula_editor/public/js/core.js b/formula_editor/public/js/core.js deleted file mode 100644 index 244992a..0000000 --- a/formula_editor/public/js/core.js +++ /dev/null @@ -1,466 +0,0 @@ -/** - * Formula Editor Core - * Contains the main FormulaEditor class and core functionality - */ - -import { - safeEval, - validateFormula, - extractReferencedFields, - generateUniqueId, - debounce, - formatErrorMessage, - showToast, - isEmpty, - deepClone, - safeParseJSON -} from './utils'; - -class FormulaEditor { - /** - * Create a new FormulaEditor instance - * @param {Object} options - Configuration options - * @param {string} options.parent - Parent element selector or element - * @param {string} options.doctype - DocType name - * @param {string} [options.fieldname] - Field name - * @param {Object} [options.doc] - Current document - * @param {Function} [options.on_change] - Change callback - * @param {Array} [options.custom_fields] - Additional custom fields - * @param {boolean} [options.is_report] - Whether this is used in a report - * @param {boolean} [options.is_trigger] - Whether this is used for triggers - */ - constructor(options) { - this.options = Object.assign({ - parent: null, - doctype: null, - fieldname: null, - doc: null, - on_change: null, - custom_fields: [], - is_report: false, - is_trigger: false - }, options); - - // Validate required options - if (!this.options.parent) { - throw new Error(__('Parent element is required for Formula Editor')); - } - - if (!this.options.doctype) { - throw new Error(__('DocType is required for Formula Editor')); - } - - // Initialize properties - this.initialized = false; - this.formula = ''; - this.fields = []; - this.errors = []; - this.meta = null; - this.parent = typeof this.options.parent === 'string' - ? document.querySelector(this.options.parent) - : this.options.parent; - this.editor_id = generateUniqueId('formula-editor'); - - // Create debounced functions - this.debouncedValidate = debounce(this.validate.bind(this), 500); - this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); - - // Internal data storage - this._cached_fields = null; - this._field_options = null; - this._referenced_fields = []; - - // Initialize the editor - this.init(); - } - - /** - * Initialize the editor - * @private - */ - async init() { - try { - if (this.initialized) return; - - // Fetch metadata for the DocType - await this.fetchMeta(); - - // Get available fields - await this.getFields(); - - // Create the UI elements - this.createElements(); - - // Bind events - this.bindEvents(); - - this.initialized = true; - - // Initialize with current value if available - if (this.options.doc && this.options.fieldname) { - this.setValue(this.options.doc[this.options.fieldname] || ''); - } - - } catch (error) { - console.error('Error initializing Formula Editor:', error); - this.showError(__('Failed to initialize Formula Editor')); - } - } - - /** - * Fetch DocType metadata - * @private - */ - async fetchMeta() { - try { - this.meta = frappe.get_meta(this.options.doctype); - - if (!this.meta) { - // If meta is not in cache, fetch it - this.meta = await frappe.model.with_doctype(this.options.doctype); - } - } catch (error) { - console.error('Error fetching metadata:', error); - throw new Error(__('Could not fetch metadata for {0}', [this.options.doctype])); - } - } - - /** - * Get available fields for the formula - * @private - */ - async getFields() { - // Return cached fields if available - if (this._cached_fields) { - this.fields = deepClone(this._cached_fields); - return; - } - - try { - const fields = []; - - // Add standard fields - this.addStandardFields(fields); - - // Add fields from the DocType - this.addDocTypeFields(fields); - - // Add custom fields if provided - if (Array.isArray(this.options.custom_fields)) { - fields.push(...this.options.custom_fields); - } - - // Save and cache the fields - this.fields = fields; - this._cached_fields = deepClone(fields); - - } catch (error) { - console.error('Error getting fields:', error); - throw new Error(__('Could not get fields for Formula Editor')); - } - } - - /** - * Add standard fields like 'doc', etc. - * @param {Array} fields - Fields array to add to - * @private - */ - addStandardFields(fields) { - // Add 'doc' as a standard field - fields.push({ - fieldname: 'doc', - label: __('Current Document'), - type: 'Object', - standard: true - }); - - // Add other standard fields based on context - if (this.options.is_report) { - fields.push({ - fieldname: 'row', - label: __('Current Row'), - type: 'Object', - standard: true - }); - } - } - - /** - * Add fields from the DocType - * @param {Array} fields - Fields array to add to - * @private - */ - addDocTypeFields(fields) { - if (!this.meta || !this.meta.fields) return; - - // Add all fields from the DocType - for (const field of this.meta.fields) { - // Skip certain field types - if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { - continue; - } - - fields.push({ - fieldname: field.fieldname, - label: field.label || field.fieldname, - type: field.fieldtype, - options: field.options, - table_fields: this.getTableFields(field), - is_child_table: field.fieldtype === 'Table' - }); - } - } - - /** - * Get fields for a Table type field - * @param {Object} field - The table field - * @returns {Array} Child table fields - * @private - */ - getTableFields(field) { - if (field.fieldtype !== 'Table' || !field.options) return []; - - const childFields = []; - const childMeta = frappe.get_meta(field.options); - - if (!childMeta || !childMeta.fields) return []; - - for (const childField of childMeta.fields) { - // Skip certain field types - if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(childField.fieldtype)) { - continue; - } - - childFields.push({ - fieldname: childField.fieldname, - label: childField.label || childField.fieldname, - type: childField.fieldtype, - options: childField.options - }); - } - - return childFields; - } - - /** - * Create UI elements for the editor - * @private - */ - createElements() { - // This is a placeholder - UI creation will be moved to ui.js - // Here we just create the basic container - this.container = document.createElement('div'); - this.container.id = this.editor_id; - this.container.className = 'formula-editor-container'; - - // Create a simple textarea for now (will be replaced by proper UI) - this.textarea = document.createElement('textarea'); - this.textarea.className = 'formula-editor-input'; - this.textarea.placeholder = __('Enter your formula here...'); - - // Error display element - this.errorEl = document.createElement('div'); - this.errorEl.className = 'formula-editor-error hidden'; - - // Append to container - this.container.appendChild(this.textarea); - this.container.appendChild(this.errorEl); - - // Append to parent - this.parent.appendChild(this.container); - } - - /** - * Bind events to the editor elements - * @private - */ - bindEvents() { - if (!this.textarea) return; - - // Input event for formula changes - this.textarea.addEventListener('input', () => { - this.formula = this.textarea.value; - this.debouncedValidate(); - this.debouncedOnChange(); - }); - - // Focus/blur events - this.textarea.addEventListener('focus', this.handleFocus.bind(this)); - this.textarea.addEventListener('blur', this.handleBlur.bind(this)); - } - - /** - * Handle focus event - * @private - */ - handleFocus() { - this.container.classList.add('focused'); - } - - /** - * Handle blur event - * @private - */ - handleBlur() { - this.container.classList.remove('focused'); - this.validate(); - } - - /** - * Handle formula change - * @private - */ - handleChange() { - if (typeof this.options.on_change === 'function') { - this.options.on_change(this.formula, this._referenced_fields); - } - } - - /** - * Set the formula value - * @param {string} value - Formula to set - * @public - */ - setValue(value) { - this.formula = value || ''; - - if (this.textarea) { - this.textarea.value = this.formula; - } - - this.validate(); - } - - /** - * Get the current formula value - * @returns {string} Current formula - * @public - */ - getValue() { - return this.formula; - } - - /** - * Validate the current formula - * @returns {boolean} Whether the formula is valid - * @public - */ - validate() { - // Clear previous errors - this.errors = []; - - // Skip validation for empty formula - if (!this.formula.trim()) { - this.hideError(); - return true; - } - - // Validate formula syntax - const validation = validateFormula(this.formula); - - if (!validation.isValid) { - this.errors.push(validation.error); - this.showError(validation.error); - return false; - } - - // Get referenced fields - this._referenced_fields = extractReferencedFields( - this.formula, - this.fields.map(f => f.fieldname) - ); - - // Hide error if validation passed - this.hideError(); - return true; - } - - /** - * Show error message - * @param {string} message - Error message - * @private - */ - showError(message) { - if (!this.errorEl) return; - - this.errorEl.textContent = formatErrorMessage(message); - this.errorEl.classList.remove('hidden'); - this.container.classList.add('has-error'); - } - - /** - * Hide error message - * @private - */ - hideError() { - if (!this.errorEl) return; - - this.errorEl.textContent = ''; - this.errorEl.classList.add('hidden'); - this.container.classList.remove('has-error'); - } - - /** - * Evaluate the current formula - * @param {Object} [context] - Additional context for evaluation - * @returns {*} Result of the evaluation - * @public - */ - evaluate(context = {}) { - if (!this.formula.trim()) return null; - - // Validate before evaluation - if (!this.validate()) { - return null; - } - - // Create evaluation context - const evalContext = { - doc: this.options.doc || {}, - ...context - }; - - try { - return safeEval(this.formula, evalContext); - } catch (error) { - console.error('Error evaluating formula:', error); - this.showError(error.message); - return null; - } - } - - /** - * Gets the fields referenced in the current formula - * @returns {Array} Array of referenced field names - * @public - */ - getReferencedFields() { - return [...this._referenced_fields]; - } - - /** - * Destroy the editor and clean up - * @public - */ - destroy() { - // Remove event listeners - if (this.textarea) { - this.textarea.removeEventListener('input', this.debouncedOnChange); - this.textarea.removeEventListener('focus', this.handleFocus); - this.textarea.removeEventListener('blur', this.handleBlur); - } - - // Remove DOM elements - if (this.container && this.container.parentNode) { - this.container.parentNode.removeChild(this.container); - } - - // Clean up properties - this.initialized = false; - this._cached_fields = null; - this._field_options = null; - this._referenced_fields = []; - } -} - -export default FormulaEditor; \ No newline at end of file diff --git a/formula_editor/public/js/index.js b/formula_editor/public/js/index.js deleted file mode 100644 index d5bded0..0000000 --- a/formula_editor/public/js/index.js +++ /dev/null @@ -1,376 +0,0 @@ -/** - * Formula Editor - * Main entry point for the Formula Editor module - */ - -import FormulaEditor from './core'; -import FormulaEditorUI from './ui'; -import FormulaEditorTriggers from './triggers'; -import FormulaEditorUniversalFields from './universal_fields'; -import FormulaEditorTableRows from './table_row_formulas'; -import FormulaEditorReportFields from './report_fields'; -import * as utils from './utils'; - -// Create the top-level namespace for the formula editor -frappe.formula_editor = { - // Core classes - FormulaEditor, - FormulaEditorUI, - FormulaEditorTriggers, - FormulaEditorUniversalFields, - FormulaEditorTableRows, - FormulaEditorReportFields, - - // Utilities - utils, - - // Instances cache - _instances: {}, - - /** - * Create a new formula editor instance - * @param {Object} options - Configuration options - * @returns {FormulaEditor} Formula editor instance - */ - create(options) { - const editor = new FormulaEditor(options); - const id = options.id || `formula_editor_${Math.random().toString(36).substr(2, 9)}`; - - this._instances[id] = editor; - return editor; - }, - - /** - * Get a formula editor instance by ID - * @param {string} id - Instance ID - * @returns {FormulaEditor|null} Formula editor instance or null if not found - */ - get(id) { - return this._instances[id] || null; - }, - - /** - * Destroy a formula editor instance - * @param {string} id - Instance ID - * @returns {boolean} Success status - */ - destroy(id) { - const editor = this.get(id); - if (!editor) return false; - - editor.destroy(); - delete this._instances[id]; - return true; - }, - - /** - * Initialize formula editor for a form field - * @param {frappe.ui.form.Field} field - Form field to initialize editor for - * @param {Object} [options={}] - Additional options - * @returns {FormulaEditor} Formula editor instance - */ - initForField(field, options = {}) { - if (!field || !field.frm || !field.df) { - console.error('Invalid field provided to initForField'); - return null; - } - - const fieldId = `${field.frm.doctype}_${field.df.fieldname}`; - - // Check if editor already exists for this field - const existingEditor = this.get(fieldId); - if (existingEditor) return existingEditor; - - // Create wrapper element - const $wrapper = $('
'); - field.$wrapper.find('.control-input-wrapper').append($wrapper); - - // Create editor - const editorOptions = { - id: fieldId, - parent: $wrapper[0], - doctype: field.frm.doctype, - fieldname: field.df.fieldname, - doc: field.frm.doc, - on_change: (value, referencedFields) => { - field.set_input(value); - - // Store referenced fields to update formula when they change - if (Array.isArray(referencedFields)) { - field._formulaReferencedFields = referencedFields; - } - }, - ...options - }; - - const editor = this.create(editorOptions); - - // Set up field change listener to keep formula in sync - field._originalRefresh = field.refresh; - field.refresh = () => { - const result = field._originalRefresh(); - - // Update editor value if it doesn't match field value - if (editor.getValue() !== field.value) { - editor.setValue(field.value); - } - - return result; - }; - - // Set up listener for referenced fields - this._setupReferencedFieldsListeners(field.frm, field); - - return editor; - }, - - /** - * Set up listeners for fields referenced in a formula - * @param {frappe.ui.form.Form} frm - Form instance - * @param {frappe.ui.form.Field} formulaField - Formula field - * @private - */ - _setupReferencedFieldsListeners(frm, formulaField) { - if (!frm || !formulaField || !formulaField._formulaReferencedFields) return; - - const referencedFields = formulaField._formulaReferencedFields; - - referencedFields.forEach(fieldname => { - const field = frm.fields_dict[fieldname]; - if (!field) return; - - // Skip if already set up - if (field._formulaListenerSetup) return; - - // Set up change listener - const originalOnChange = field.df.change; - field.df.change = () => { - if (typeof originalOnChange === 'function') { - originalOnChange(); - } - - // Update all formula fields that reference this field - Object.values(frm.fields_dict).forEach(f => { - if (f._formulaReferencedFields && - f._formulaReferencedFields.includes(fieldname) && - f !== formulaField) { - - // Get the editor - const editorId = `${frm.doctype}_${f.df.fieldname}`; - const editor = frappe.formula_editor.get(editorId); - - if (editor) { - // Re-evaluate the formula - const result = editor.evaluate(); - if (result !== undefined) { - f.set_input(result); - } - } - } - }); - }; - - field._formulaListenerSetup = true; - }); - }, - - /** - * Initialize formula editor for a report - * @param {frappe.views.ReportView|frappe.query_reports.QueryReport} report - Report instance - * @param {string} reportName - Report name - * @param {string} [reportType='report'] - Report type - * @returns {FormulaEditorReportFields} Report fields handler instance - */ - initForReport(report, reportName, reportType = 'report') { - if (!report || !reportName) { - console.error('Invalid report provided to initForReport'); - return null; - } - - const reportId = `${reportType}_${reportName}`; - - // Check if already initialized - if (report._formulaEditor) return report._formulaEditor; - - // Create a minimal editor instance for the report - const editorOptions = { - id: reportId, - doctype: reportName, - is_report: true - }; - - const editor = new FormulaEditor(editorOptions); - - // Create report fields handler - const reportFields = new FormulaEditorReportFields({ - editor: editor, - reportFormulas: [] - }); - - // Integrate with the report - reportFields.integrateWithReport(report, reportName, reportType); - - // Store reference on the report - report._formulaEditor = reportFields; - - return reportFields; - }, - - /** - * Initialize table row formulas for a form - * @param {frappe.ui.form.Form} frm - Form instance - * @param {Object} [options={}] - Additional options - * @returns {FormulaEditorTableRows} Table rows handler instance - */ - initTableFormulas(frm, options = {}) { - if (!frm || !frm.doctype) { - console.error('Invalid form provided to initTableFormulas'); - return null; - } - - const formId = `table_formulas_${frm.doctype}`; - - // Check if already initialized - if (frm._tableFormulasEditor) return frm._tableFormulasEditor; - - // Create a minimal editor instance for the form - const editorOptions = { - id: formId, - doctype: frm.doctype, - doc: frm.doc - }; - - const editor = new FormulaEditor(editorOptions); - - // Create table rows handler - const tableRows = new FormulaEditorTableRows({ - editor: editor, - tableFormulas: options.tableFormulas || [], - onChange: options.onChange - }); - - // Attach to the form - tableRows.attachToForm(frm); - - // Store reference on the form - frm._tableFormulasEditor = tableRows; - - return tableRows; - }, - - /** - * Initialize triggers for a form - * @param {frappe.ui.form.Form} frm - Form instance - * @param {Object} [options={}] - Additional options - * @returns {FormulaEditorTriggers} Triggers handler instance - */ - initTriggers(frm, options = {}) { - if (!frm || !frm.doctype) { - console.error('Invalid form provided to initTriggers'); - return null; - } - - const formId = `triggers_${frm.doctype}`; - - // Check if already initialized - if (frm._triggersEditor) return frm._triggersEditor; - - // Create a minimal editor instance for the form - const editorOptions = { - id: formId, - doctype: frm.doctype, - doc: frm.doc, - is_trigger: true - }; - - const editor = new FormulaEditor(editorOptions); - - // Create triggers handler - const triggers = new FormulaEditorTriggers({ - editor: editor, - triggers: options.triggers || [], - onChange: options.onChange - }); - - // Attach to the form - triggers.attachToForm(frm); - - // Store reference on the form - frm._triggersEditor = triggers; - - return triggers; - }, - - /** - * Make a field into a formula editor - * @param {frappe.ui.form.Form} frm - Form instance - * @param {string} fieldname - Field to convert - * @param {Object} [options={}] - Additional options - */ - makeFieldFormula(frm, fieldname, options = {}) { - if (!frm || !fieldname) return; - - const field = frm.get_field(fieldname); - if (!field) return; - - // Add formula button to the field - if (!field.$wrapper.find('.formula-editor-btn').length) { - const $btn = $(` - - `); - - field.$wrapper.find('.control-input-wrapper').append($btn); - - // Show formula editor on click - $btn.on('click', () => { - this.showFieldFormulaDialog(frm, fieldname, options); - }); - } - }, - - /** - * Show formula editor dialog for a field - * @param {frappe.ui.form.Form} frm - Form instance - * @param {string} fieldname - Field name - * @param {Object} [options={}] - Additional options - */ - showFieldFormulaDialog(frm, fieldname, options = {}) { - const field = frm.get_field(fieldname); - if (!field) return; - - // Create dialog - const dialog = new frappe.ui.Dialog({ - title: __('Formula Editor: {0}', [field.df.label || fieldname]), - fields: [ - { - fieldtype: 'Code', - fieldname: 'formula', - label: __('Formula'), - options: 'JavaScript', - default: field.value || '' - } - ], - primary_action_label: __('Apply'), - primary_action: (values) => { - field.set_input(values.formula); - dialog.hide(); - } - }); - - dialog.show(); - } -}; - -// Register setup hooks -$(document).on('form-load', function(event, frm) { - // Find formula fields and convert them to formula editors - frm.meta.fields.forEach(df => { - if (df.fieldtype === 'Code' && df.options === 'Formula') { - frappe.formula_editor.makeFieldFormula(frm, df.fieldname); - } - }); -}); - -export default frappe.formula_editor; \ No newline at end of file diff --git a/formula_editor/public/js/report_fields.js b/formula_editor/public/js/report_fields.js deleted file mode 100644 index 53882e2..0000000 --- a/formula_editor/public/js/report_fields.js +++ /dev/null @@ -1,1161 +0,0 @@ -/** - * Formula Editor Report Fields - * Handles formula functionality for reports and analytical views - */ - -import { - safeEval, - validateFormula, - formatErrorMessage, - debounce, - isEmpty, - deepClone, - showToast -} from './utils'; - -class FormulaEditorReportFields { - /** - * Create a new FormulaEditorReportFields instance - * @param {Object} options - Configuration options - * @param {Object} options.editor - Reference to the main FormulaEditor instance - * @param {Array} [options.reportFormulas=[]] - Initial report formulas - * @param {Function} [options.onChange] - Callback for formula changes - */ - constructor(options) { - this.options = Object.assign({ - editor: null, - reportFormulas: [], - onChange: null - }, options); - - // Validate required options - if (!this.options.editor) { - throw new Error(__('Editor instance is required for FormulaEditorReportFields')); - } - - // Initialize properties - this.editor = this.options.editor; - this.reportFormulas = deepClone(this.options.reportFormulas || []); - this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); - - // Initialize - this.init(); - } - - /** - * Initialize report formulas - * @private - */ - init() { - // Ensure all report formulas have proper structure - this.validateReportFormulas(); - } - - /** - * Validate report formulas structure - * @private - */ - validateReportFormulas() { - if (!Array.isArray(this.reportFormulas)) { - this.reportFormulas = []; - return; - } - - // Process each report formula to ensure it has all required properties - this.reportFormulas = this.reportFormulas.map(formula => { - // Skip if not an object - if (typeof formula !== 'object' || formula === null) { - return null; - } - - // Ensure required properties - return { - id: formula.id || `report_formula_${Date.now()}`, - name: formula.name || __('Unnamed Report Formula'), - formula: formula.formula || '', - reportType: formula.reportType || 'report', // 'report', 'query-report', 'custom' - reportName: formula.reportName || '', - fieldName: formula.fieldName || '', - columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'), - position: formula.position || 'end', // 'start', 'end', after:fieldname - positionField: formula.positionField || null, - is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true, - format: formula.format || null, // 'Currency', 'Percent', etc. - enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, - description: formula.description || '', - precision: formula.precision !== undefined ? formula.precision : 2, - aggregation: formula.aggregation || null // 'sum', 'avg', 'min', 'max', 'count' - }; - }).filter(Boolean); // Remove invalid formulas - } - - /** - * Get all report formulas - * @returns {Array} List of report formulas - * @public - */ - getReportFormulas() { - return deepClone(this.reportFormulas); - } - - /** - * Get report formulas for a specific report - * @param {string} reportName - Report name - * @param {string} [reportType='report'] - Report type ('report', 'query-report', 'custom') - * @returns {Array} List of formulas for the report - * @public - */ - getFormulasForReport(reportName, reportType = 'report') { - return this.reportFormulas.filter(f => - f.reportName === reportName && f.reportType === reportType - ).map(f => deepClone(f)); - } - - /** - * Get a report formula by ID - * @param {string} id - Formula ID - * @returns {Object|null} Formula object or null if not found - * @public - */ - getReportFormula(id) { - const formula = this.reportFormulas.find(f => f.id === id); - return formula ? deepClone(formula) : null; - } - - /** - * Add a new report formula - * @param {Object} formula - Formula data - * @returns {string} New formula ID - * @public - */ - addReportFormula(formula = {}) { - if (!formula.reportName) { - throw new Error(__('Report name is required')); - } - - const id = formula.id || `report_formula_${Date.now()}`; - - const newFormula = { - id: id, - name: formula.name || __('Unnamed Report Formula'), - formula: formula.formula || '', - reportType: formula.reportType || 'report', // 'report', 'query-report', 'custom' - reportName: formula.reportName, - fieldName: formula.fieldName || `calculated_${id.substring(id.length - 6)}`, - columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'), - position: formula.position || 'end', // 'start', 'end', after:fieldname - positionField: formula.positionField || null, - is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true, - format: formula.format || null, // 'Currency', 'Percent', etc. - enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, - description: formula.description || '', - precision: formula.precision !== undefined ? formula.precision : 2, - aggregation: formula.aggregation || null // 'sum', 'avg', 'min', 'max', 'count' - }; - - this.reportFormulas.push(newFormula); - this.debouncedOnChange(); - - return newFormula.id; - } - - /** - * Update a report formula - * @param {string} id - Formula ID - * @param {Object} updates - Properties to update - * @returns {boolean} Success status - * @public - */ - updateReportFormula(id, updates) { - const index = this.reportFormulas.findIndex(f => f.id === id); - if (index === -1) return false; - - // Clone the formula - const formula = deepClone(this.reportFormulas[index]); - - // Apply updates - Object.keys(updates).forEach(key => { - if (key !== 'id') { // Don't allow changing the ID - formula[key] = updates[key]; - } - }); - - // Update the formula - this.reportFormulas[index] = formula; - this.debouncedOnChange(); - - return true; - } - - /** - * Delete a report formula - * @param {string} id - Formula ID - * @returns {boolean} Success status - * @public - */ - deleteReportFormula(id) { - const index = this.reportFormulas.findIndex(f => f.id === id); - if (index === -1) return false; - - this.reportFormulas.splice(index, 1); - this.debouncedOnChange(); - - return true; - } - - /** - * Enable/disable a report formula - * @param {string} id - Formula ID - * @param {boolean} enabled - Whether to enable the formula - * @returns {boolean} Success status - * @public - */ - setReportFormulaEnabled(id, enabled) { - return this.updateReportFormula(id, { enabled: !!enabled }); - } - - /** - * Validate a report formula - * @param {string} id - Formula ID, or formula string if testOnly is true - * @param {boolean} [testOnly=false] - Whether to validate a formula string instead of a saved formula - * @returns {Object} Validation result with isValid and error - * @public - */ - validateReportFormula(id, testOnly = false) { - let formula; - - if (testOnly) { - formula = id; // In this case, id is actually the formula string - } else { - const formulaObj = this.getReportFormula(id); - if (!formulaObj) { - return { isValid: false, error: __('Formula not found') }; - } - formula = formulaObj.formula; - } - - if (!formula || typeof formula !== 'string' || !formula.trim()) { - return { isValid: true, error: null }; - } - - // Use basic formula validation - return validateFormula(formula); - } - - /** - * Apply formulas to report data - * @param {Array} data - Report data rows - * @param {Array} columns - Report columns - * @param {string} reportName - Report name - * @param {string} [reportType='report'] - Report type ('report', 'query-report', 'custom') - * @returns {Object} Modified data and columns - * @public - */ - applyFormulasToReport(data, columns, reportName, reportType = 'report') { - try { - if (!Array.isArray(data) || !Array.isArray(columns) || !reportName) { - return { data, columns }; - } - - // Get formulas for this report - const formulas = this.getFormulasForReport(reportName, reportType) - .filter(f => f.enabled); - - if (formulas.length === 0) { - return { data, columns }; - } - - // Create a column map for easy lookup - const columnMap = {}; - columns.forEach((col, index) => { - columnMap[col.fieldname] = index; - }); - - // Process each formula - const newData = deepClone(data); - const newColumns = deepClone(columns); - - formulas.forEach(formula => { - try { - // Check if column already exists - const existingColumnIndex = columnMap[formula.fieldName]; - - // Create new column if needed - if (existingColumnIndex === undefined) { - const newColumn = { - fieldname: formula.fieldName, - label: formula.columnLabel, - fieldtype: formula.is_numeric ? 'Float' : 'Data', - options: formula.format, - precision: formula.precision, - width: 120, - is_formula: true, - formula_reference: formula.id - }; - - // Determine position of the new column - if (formula.position === 'start') { - newColumns.unshift(newColumn); - - // Update column map - Object.keys(columnMap).forEach(key => { - columnMap[key]++; - }); - columnMap[formula.fieldName] = 0; - } else if (formula.position === 'end' || !formula.positionField) { - newColumns.push(newColumn); - columnMap[formula.fieldName] = newColumns.length - 1; - } else { - // Position after specific field - const posIndex = columnMap[formula.positionField]; - if (posIndex !== undefined) { - newColumns.splice(posIndex + 1, 0, newColumn); - - // Update column map - Object.keys(columnMap).forEach(key => { - if (columnMap[key] > posIndex) { - columnMap[key]++; - } - }); - columnMap[formula.fieldName] = posIndex + 1; - } else { - // Fallback to end - newColumns.push(newColumn); - columnMap[formula.fieldName] = newColumns.length - 1; - } - } - } - - // Apply formula to each row - newData.forEach((row, rowIndex) => { - try { - const context = { - row: row, - data: newData, - rowIndex: rowIndex, - columns: newColumns, - columnMap: columnMap, - reportName: reportName, - reportType: reportType - }; - - const result = this.evaluateReportFormula(formula.formula, context); - - // Find where to put the result - const targetIdx = columnMap[formula.fieldName]; - - if (targetIdx !== undefined) { - // If row is an array, extend it if needed - if (Array.isArray(row)) { - if (row.length <= targetIdx) { - while (row.length < targetIdx) { - row.push(null); - } - row.push(result); - } else { - row[targetIdx] = result; - } - } - // If row is an object, add/update the property - else if (typeof row === 'object' && row !== null) { - row[formula.fieldName] = result; - } - } - } catch (rowError) { - console.error(`Error applying formula to row ${rowIndex}:`, rowError); - // Set error value - const targetIdx = columnMap[formula.fieldName]; - if (targetIdx !== undefined) { - if (Array.isArray(row)) { - row[targetIdx] = '#ERROR'; - } else if (typeof row === 'object' && row !== null) { - row[formula.fieldName] = '#ERROR'; - } - } - } - }); - - // Apply aggregation if specified - if (formula.aggregation && formula.is_numeric) { - this.applyAggregation(newData, formula); - } - - } catch (formulaError) { - console.error(`Error processing formula ${formula.id}:`, formulaError); - } - }); - - return { data: newData, columns: newColumns }; - - } catch (error) { - console.error('Error applying formulas to report:', error); - showToast(__('Error applying formulas to report: {0}', [formatErrorMessage(error)]), 'error'); - return { data, columns }; - } - } - - /** - * Evaluate a formula in the context of a report row - * @param {string} formula - Formula to evaluate - * @param {Object} context - Evaluation context - * @returns {*} Evaluation result - * @private - */ - evaluateReportFormula(formula, context) { - return safeEval(formula, context); - } - - /** - * Apply aggregation to a formula field - * @param {Array} data - Report data - * @param {Object} formula - Formula configuration - * @private - */ - applyAggregation(data, formula) { - if (!Array.isArray(data) || data.length === 0) return; - - const fieldName = formula.fieldName; - let result; - - switch (formula.aggregation) { - case 'sum': - result = this.calculateSum(data, fieldName); - break; - case 'avg': - result = this.calculateAverage(data, fieldName); - break; - case 'min': - result = this.calculateMin(data, fieldName); - break; - case 'max': - result = this.calculateMax(data, fieldName); - break; - case 'count': - result = this.calculateCount(data, fieldName); - break; - default: - return; - } - - // Look for total row (last row or one with is_total=true) - let totalRow = null; - - for (let i = data.length - 1; i >= 0; i--) { - const row = data[i]; - - if (typeof row === 'object' && row !== null && row.is_total_row) { - totalRow = row; - break; - } - } - - // If no total row found, create one - if (!totalRow) { - totalRow = { is_total_row: true }; - data.push(totalRow); - } - - // Set the aggregated value - if (typeof totalRow === 'object') { - totalRow[fieldName] = result; - } - } - - /** - * Calculate sum of a field across all rows - * @param {Array} data - Report data - * @param {string} fieldName - Field to sum - * @returns {number} Sum value - * @private - */ - calculateSum(data, fieldName) { - let sum = 0; - - data.forEach(row => { - if (row.is_total_row) return; // Skip total rows - - const value = this.getRowValue(row, fieldName); - if (typeof value === 'number' && !isNaN(value)) { - sum += value; - } - }); - - return sum; - } - - /** - * Calculate average of a field across all rows - * @param {Array} data - Report data - * @param {string} fieldName - Field to average - * @returns {number} Average value - * @private - */ - calculateAverage(data, fieldName) { - let sum = 0; - let count = 0; - - data.forEach(row => { - if (row.is_total_row) return; // Skip total rows - - const value = this.getRowValue(row, fieldName); - if (typeof value === 'number' && !isNaN(value)) { - sum += value; - count++; - } - }); - - return count > 0 ? sum / count : 0; - } - - /** - * Calculate minimum value of a field across all rows - * @param {Array} data - Report data - * @param {string} fieldName - Field to check - * @returns {number} Minimum value - * @private - */ - calculateMin(data, fieldName) { - let min = Infinity; - - data.forEach(row => { - if (row.is_total_row) return; // Skip total rows - - const value = this.getRowValue(row, fieldName); - if (typeof value === 'number' && !isNaN(value) && value < min) { - min = value; - } - }); - - return min === Infinity ? 0 : min; - } - - /** - * Calculate maximum value of a field across all rows - * @param {Array} data - Report data - * @param {string} fieldName - Field to check - * @returns {number} Maximum value - * @private - */ - calculateMax(data, fieldName) { - let max = -Infinity; - - data.forEach(row => { - if (row.is_total_row) return; // Skip total rows - - const value = this.getRowValue(row, fieldName); - if (typeof value === 'number' && !isNaN(value) && value > max) { - max = value; - } - }); - - return max === -Infinity ? 0 : max; - } - - /** - * Count non-empty values of a field across all rows - * @param {Array} data - Report data - * @param {string} fieldName - Field to count - * @returns {number} Count of non-empty values - * @private - */ - calculateCount(data, fieldName) { - let count = 0; - - data.forEach(row => { - if (row.is_total_row) return; // Skip total rows - - const value = this.getRowValue(row, fieldName); - if (value !== null && value !== undefined && value !== '') { - count++; - } - }); - - return count; - } - - /** - * Get a value from a row, handling both array and object formats - * @param {Object|Array} row - Report row - * @param {string} fieldName - Field name - * @returns {*} Field value - * @private - */ - getRowValue(row, fieldName) { - if (Array.isArray(row)) { - // Find the column index - const columns = this.options.editor.reportColumns || []; - const colIndex = columns.findIndex(c => c.fieldname === fieldName); - - return colIndex !== -1 ? row[colIndex] : null; - } else if (typeof row === 'object' && row !== null) { - return row[fieldName]; - } - - return null; - } - - /** - * Integrate with a Frappe report - * @param {Object} report - Frappe report instance - * @param {string} reportName - Report name - * @param {string} [reportType='report'] - Report type - * @returns {boolean} Success status - * @public - */ - integrateWithReport(report, reportName, reportType = 'report') { - if (!report || !reportName) { - return false; - } - - try { - // Store reference to the report - this.currentReport = report; - this.currentReportName = reportName; - this.currentReportType = reportType; - - // Save original functions - if (!report._original_get_data) { - report._original_get_data = report.get_data; - - // Override get_data to apply formulas - report.get_data = (...args) => { - const result = report._original_get_data.apply(report, args); - - if (result && typeof result.then === 'function') { - // It's a promise - return result.then(response => { - const data = response.data || response; - const columns = response.columns || report.columns; - - // Apply formulas to the data - const modified = this.applyFormulasToReport( - data, - columns, - reportName, - reportType - ); - - // Update the response - if (typeof response === 'object') { - response.data = modified.data; - response.columns = modified.columns; - return response; - } - - return modified.data; - }); - } else { - // It's direct data - const data = result || []; - const columns = report.columns || []; - - // Apply formulas to the data - const modified = this.applyFormulasToReport( - data, - columns, - reportName, - reportType - ); - - // Update the report columns - report.columns = modified.columns; - - return modified.data; - } - }; - } - - // Also hook into refresh if available - if (report.refresh && !report._original_refresh) { - report._original_refresh = report.refresh; - - report.refresh = () => { - // Call original refresh - const result = report._original_refresh.apply(report); - - // Add formula controls to the report - setTimeout(() => { - this.addFormulaControlsToReport(report, reportName, reportType); - }, 500); - - return result; - }; - } - - return true; - - } catch (error) { - console.error('Error integrating with report:', error); - return false; - } - } - - /** - * Add formula controls to a report UI - * @param {Object} report - Frappe report instance - * @param {string} reportName - Report name - * @param {string} reportType - Report type - * @private - */ - addFormulaControlsToReport(report, reportName, reportType) { - try { - // Check if controls already exist - if (report.$formulaControls) return; - - // Find report toolbar - const $toolbar = $(report.wrapper).find('.report-action-buttons, .grid-report-action-buttons'); - if (!$toolbar.length) return; - - // Create formula button - const $formulaBtn = $(` - - `); - - // Add button to toolbar - $toolbar.append($formulaBtn); - - // Store reference - report.$formulaControls = $formulaBtn; - - // Add click handler - $formulaBtn.on('click', () => { - this.showReportFormulaDialog(reportName, reportType, report); - }); - - } catch (error) { - console.error('Error adding formula controls to report:', error); - } - } - - /** - * Show dialog to manage report formulas - * @param {string} reportName - Report name - * @param {string} reportType - Report type - * @param {Object} report - Report instance - * @private - */ - showReportFormulaDialog(reportName, reportType, report) { - const formulas = this.getFormulasForReport(reportName, reportType); - - // Get available columns - const columns = report.columns || []; - const columnOptions = columns.map(col => ({ - label: col.label || col.fieldname, - value: col.fieldname - })); - - // Create dialog - const dialog = new frappe.ui.Dialog({ - title: __('Report Formulas'), - fields: [ - { - fieldtype: 'HTML', - fieldname: 'formula_list', - label: __('Formulas'), - options: `
` - }, - { - fieldtype: 'Section Break', - label: __('Add New Formula') - }, - { - fieldtype: 'Data', - fieldname: 'name', - label: __('Name'), - reqd: true - }, - { - fieldtype: 'Code', - fieldname: 'formula', - label: __('Formula'), - options: 'JavaScript', - description: __('Use row to access the current row. Example: row.amount * 1.1') - }, - { - fieldtype: 'Column Break' - }, - { - fieldtype: 'Data', - fieldname: 'fieldName', - label: __('Field Name'), - description: __('Unique identifier for this formula field') - }, - { - fieldtype: 'Data', - fieldname: 'columnLabel', - label: __('Column Label'), - description: __('Display label for the column') - }, - { - fieldtype: 'Section Break' - }, - { - fieldtype: 'Select', - fieldname: 'position', - label: __('Position'), - options: [ - { label: __('At Start'), value: 'start' }, - { label: __('At End'), value: 'end' }, - { label: __('After Field'), value: 'after' } - ], - default: 'end' - }, - { - fieldtype: 'Select', - fieldname: 'positionField', - label: __('After Field'), - options: columnOptions, - depends_on: 'eval:doc.position === "after"' - }, - { - fieldtype: 'Column Break' - }, - { - fieldtype: 'Check', - fieldname: 'is_numeric', - label: __('Numeric'), - default: 1 - }, - { - fieldtype: 'Select', - fieldname: 'format', - label: __('Format'), - options: [ - { label: __('None'), value: '' }, - { label: __('Currency'), value: 'Currency' }, - { label: __('Percent'), value: 'Percent' }, - { label: __('Number'), value: 'Number' } - ], - default: '' - }, - { - fieldtype: 'Int', - fieldname: 'precision', - label: __('Precision'), - default: 2 - }, - { - fieldtype: 'Section Break' - }, - { - fieldtype: 'Select', - fieldname: 'aggregation', - label: __('Aggregation'), - options: [ - { label: __('None'), value: '' }, - { label: __('Sum'), value: 'sum' }, - { label: __('Average'), value: 'avg' }, - { label: __('Minimum'), value: 'min' }, - { label: __('Maximum'), value: 'max' }, - { label: __('Count'), value: 'count' } - ], - default: '' - }, - { - fieldtype: 'Text', - fieldname: 'description', - label: __('Description') - } - ], - primary_action_label: __('Add Formula'), - primary_action: (values) => { - // Generate field name if not provided - if (!values.fieldName) { - values.fieldName = `calculated_${Math.random().toString(36).substr(2, 6)}`; - } - - // Add the formula - this.addReportFormula({ - name: values.name, - formula: values.formula, - reportType: reportType, - reportName: reportName, - fieldName: values.fieldName, - columnLabel: values.columnLabel || values.name, - position: values.position, - positionField: values.position === 'after' ? values.positionField : null, - is_numeric: values.is_numeric, - format: values.format, - precision: values.precision, - aggregation: values.aggregation, - description: values.description - }); - - // Refresh formulas list - this.refreshFormulasList(dialog); - - // Clear form - dialog.set_values({ - name: '', - formula: '', - fieldName: '', - columnLabel: '', - description: '' - }); - - // Refresh report - if (report && report.refresh) { - report.refresh(); - } - } - }); - - // Render formulas list - dialog.show(); - this.refreshFormulasList(dialog); - } - - /** - * Refresh the formulas list in the dialog - * @param {Object} dialog - Frappe dialog instance - * @private - */ - refreshFormulasList(dialog) { - const $list = dialog.$wrapper.find('.formula-list'); - const formulas = this.getFormulasForReport( - this.currentReportName, - this.currentReportType - ); - - if (formulas.length === 0) { - $list.html(`
${__('No formulas defined for this report')}
`); - return; - } - - let html = '
'; - - formulas.forEach((formula, index) => { - html += ` -
-
- ${formula.name} - ${formula.columnLabel} (${formula.fieldName}) -
- - - -
-
-
${formula.formula}
- ${formula.description ? `
${formula.description}
` : ''} -
- `; - }); - - html += '
'; - $list.html(html); - - // Add event handlers - $list.find('.formula-edit-btn').on('click', (e) => { - const $item = $(e.target).closest('.formula-list-item'); - const id = $item.data('id'); - this.editReportFormula(id, dialog); - }); - - $list.find('.formula-toggle-btn').on('click', (e) => { - const $item = $(e.target).closest('.formula-list-item'); - const id = $item.data('id'); - const formula = this.getReportFormula(id); - - if (formula) { - this.setReportFormulaEnabled(id, !formula.enabled); - this.refreshFormulasList(dialog); - - // Refresh report - if (this.currentReport && this.currentReport.refresh) { - this.currentReport.refresh(); - } - } - }); - - $list.find('.formula-delete-btn').on('click', (e) => { - const $item = $(e.target).closest('.formula-list-item'); - const id = $item.data('id'); - - frappe.confirm( - __('Delete this formula?'), - () => { - this.deleteReportFormula(id); - this.refreshFormulasList(dialog); - - // Refresh report - if (this.currentReport && this.currentReport.refresh) { - this.currentReport.refresh(); - } - } - ); - }); - } - - /** - * Edit a report formula - * @param {string} id - Formula ID - * @param {Object} dialog - Frappe dialog instance - * @private - */ - editReportFormula(id, dialog) { - const formula = this.getReportFormula(id); - if (!formula) return; - - // Switch dialog to edit mode - dialog.set_title(__('Edit Formula')); - - // Set values - dialog.set_values({ - name: formula.name, - formula: formula.formula, - fieldName: formula.fieldName, - columnLabel: formula.columnLabel, - position: formula.position, - positionField: formula.positionField, - is_numeric: formula.is_numeric, - format: formula.format, - precision: formula.precision, - aggregation: formula.aggregation, - description: formula.description - }); - - // Change primary action - dialog.set_primary_action_label(__('Update Formula')); - - // Store original primary action - const originalAction = dialog.primary_action; - - // Set new primary action - dialog.primary_action = (values) => { - // Update the formula - this.updateReportFormula(id, { - name: values.name, - formula: values.formula, - fieldName: values.fieldName, - columnLabel: values.columnLabel || values.name, - position: values.position, - positionField: values.position === 'after' ? values.positionField : null, - is_numeric: values.is_numeric, - format: values.format, - precision: values.precision, - aggregation: values.aggregation, - description: values.description - }); - - // Refresh formulas list - this.refreshFormulasList(dialog); - - // Reset dialog - dialog.set_title(__('Report Formulas')); - dialog.set_primary_action_label(__('Add Formula')); - dialog.primary_action = originalAction; - - // Clear form - dialog.set_values({ - name: '', - formula: '', - fieldName: '', - columnLabel: '', - description: '' - }); - - // Refresh report - if (this.currentReport && this.currentReport.refresh) { - this.currentReport.refresh(); - } - }; - } - - /** - * Handle formula changes - * @private - */ - handleChange() { - if (typeof this.options.onChange === 'function') { - this.options.onChange(this.reportFormulas); - } - } - - /** - * Import report formulas from JSON - * @param {string|Object} json - JSON string or object - * @returns {boolean} Success status - * @public - */ - importReportFormulas(json) { - try { - let data; - - if (typeof json === 'string') { - data = JSON.parse(json); - } else if (typeof json === 'object' && json !== null) { - data = json; - } else { - throw new Error(__('Invalid import data format')); - } - - if (!Array.isArray(data)) { - throw new Error(__('Import data must be an array')); - } - - // Validate each formula - const validFormulas = data.filter(formula => - typeof formula === 'object' && - formula !== null && - typeof formula.reportName === 'string' && - typeof formula.fieldName === 'string' - ); - - if (validFormulas.length === 0) { - throw new Error(__('No valid report formulas found in import data')); - } - - // Replace report formulas - this.reportFormulas = validFormulas.map(formula => ({ - id: formula.id || `report_formula_${Date.now()}`, - name: formula.name || __('Unnamed Report Formula'), - formula: formula.formula || '', - reportType: formula.reportType || 'report', - reportName: formula.reportName, - fieldName: formula.fieldName, - columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'), - position: formula.position || 'end', - positionField: formula.positionField || null, - is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true, - format: formula.format || null, - enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, - description: formula.description || '', - precision: formula.precision !== undefined ? formula.precision : 2, - aggregation: formula.aggregation || null - })); - - this.debouncedOnChange(); - return true; - - } catch (error) { - console.error('Error importing report formulas:', error); - return false; - } - } - - /** - * Export report formulas to JSON - * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON - * @returns {string} JSON string - * @public - */ - exportReportFormulas(prettyPrint = false) { - try { - return JSON.stringify(this.reportFormulas, null, prettyPrint ? 2 : 0); - } catch (error) { - console.error('Error exporting report formulas:', error); - return '[]'; - } - } -} - -export default FormulaEditorReportFields; \ No newline at end of file diff --git a/formula_editor/public/js/table_row_formulas.js b/formula_editor/public/js/table_row_formulas.js deleted file mode 100644 index b41efdd..0000000 --- a/formula_editor/public/js/table_row_formulas.js +++ /dev/null @@ -1,851 +0,0 @@ -/** - * Formula Editor Table Row Formulas - * Handles formulas for table rows in documents - */ - -import { - safeEval, - validateFormula, - extractReferencedFields, - formatErrorMessage, - debounce, - isEmpty, - deepClone, - showToast -} from './utils'; - -class FormulaEditorTableRows { - /** - * Create a new FormulaEditorTableRows instance - * @param {Object} options - Configuration options - * @param {Object} options.editor - Reference to the main FormulaEditor instance - * @param {Array} [options.tableFormulas=[]] - Initial table formulas - * @param {Function} [options.onChange] - Callback for formula changes - */ - constructor(options) { - this.options = Object.assign({ - editor: null, - tableFormulas: [], - onChange: null - }, options); - - // Validate required options - if (!this.options.editor) { - throw new Error(__('Editor instance is required for FormulaEditorTableRows')); - } - - // Initialize properties - this.editor = this.options.editor; - this.tableFormulas = deepClone(this.options.tableFormulas || []); - this.childTableFields = []; - this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); - - // Initialize - this.init(); - } - - /** - * Initialize table formulas - * @private - */ - init() { - // Find all child table fields in the doctype - this.loadChildTableFields(); - - // Ensure all table formulas have proper structure - this.validateTableFormulas(); - } - - /** - * Load child table fields from the doctype - * @private - */ - loadChildTableFields() { - const allFields = this.editor.fields || []; - - // Filter to only table type fields - this.childTableFields = allFields.filter(field => - field.type === 'Table' && field.options - ); - } - - /** - * Validate table formulas structure and add missing properties - * @private - */ - validateTableFormulas() { - if (!Array.isArray(this.tableFormulas)) { - this.tableFormulas = []; - return; - } - - // Process each table formula to ensure it has all required properties - this.tableFormulas = this.tableFormulas.map(formula => { - // Skip if not an object - if (typeof formula !== 'object' || formula === null) { - return null; - } - - // Check if table field exists - const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field); - if (!tableField) { - return null; - } - - // Ensure required properties - return { - id: formula.id || `${formula.table_field}_${formula.target_field}_formula`, - name: formula.name || __('Formula for {0}', [formula.target_field]), - table_field: formula.table_field, - target_field: formula.target_field, - source_fields: Array.isArray(formula.source_fields) ? formula.source_fields : [], - formula: formula.formula || '', - child_doctype: tableField.options, - enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, - apply_on: formula.apply_on || 'change', // change, save, manual - is_cumulative: !!formula.is_cumulative, - description: formula.description || '' - }; - }).filter(Boolean); // Remove invalid formulas - } - - /** - * Get all table formulas - * @returns {Array} List of table formulas - * @public - */ - getTableFormulas() { - return deepClone(this.tableFormulas); - } - - /** - * Get a table formula by ID - * @param {string} id - Formula ID - * @returns {Object|null} Formula object or null if not found - * @public - */ - getTableFormula(id) { - const formula = this.tableFormulas.find(f => f.id === id); - return formula ? deepClone(formula) : null; - } - - /** - * Get table formulas for a specific table field - * @param {string} tableField - Table field name - * @returns {Array} List of formulas for the table field - * @public - */ - getFormulasForTable(tableField) { - return this.tableFormulas.filter(f => f.table_field === tableField).map(f => deepClone(f)); - } - - /** - * Add a new table formula - * @param {Object} formula - Formula data - * @returns {string} New formula ID - * @public - */ - addTableFormula(formula = {}) { - if (!formula.table_field || !formula.target_field) { - throw new Error(__('Table field and target field are required')); - } - - // Check if table field exists - const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field); - if (!tableField) { - throw new Error(__('Table field {0} not found', [formula.table_field])); - } - - const newFormula = { - id: formula.id || `${formula.table_field}_${formula.target_field}_formula`, - name: formula.name || __('Formula for {0}', [formula.target_field]), - table_field: formula.table_field, - target_field: formula.target_field, - source_fields: Array.isArray(formula.source_fields) ? deepClone(formula.source_fields) : [], - formula: formula.formula || '', - child_doctype: tableField.options, - enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, - apply_on: formula.apply_on || 'change', - is_cumulative: !!formula.is_cumulative, - description: formula.description || '' - }; - - this.tableFormulas.push(newFormula); - this.debouncedOnChange(); - - return newFormula.id; - } - - /** - * Update a table formula - * @param {string} id - Formula ID - * @param {Object} updates - Properties to update - * @returns {boolean} Success status - * @public - */ - updateTableFormula(id, updates) { - const index = this.tableFormulas.findIndex(f => f.id === id); - if (index === -1) return false; - - // Clone the formula - const formula = deepClone(this.tableFormulas[index]); - - // Apply updates - Object.keys(updates).forEach(key => { - // Handle special properties - if (key === 'source_fields' && Array.isArray(updates.source_fields)) { - formula.source_fields = deepClone(updates.source_fields); - } else if (key === 'table_field') { - // If table field is updated, verify it exists - const tableField = this.childTableFields.find(f => f.fieldname === updates.table_field); - if (tableField) { - formula.table_field = updates.table_field; - formula.child_doctype = tableField.options; - } - } else if (key !== 'id' && key !== 'child_doctype') { // Don't allow changing ID or child_doctype directly - formula[key] = updates[key]; - } - }); - - // Update the formula - this.tableFormulas[index] = formula; - this.debouncedOnChange(); - - return true; - } - - /** - * Delete a table formula - * @param {string} id - Formula ID - * @returns {boolean} Success status - * @public - */ - deleteTableFormula(id) { - const index = this.tableFormulas.findIndex(f => f.id === id); - if (index === -1) return false; - - this.tableFormulas.splice(index, 1); - this.debouncedOnChange(); - - return true; - } - - /** - * Enable/disable a table formula - * @param {string} id - Formula ID - * @param {boolean} enabled - Whether to enable the formula - * @returns {boolean} Success status - * @public - */ - setTableFormulaEnabled(id, enabled) { - return this.updateTableFormula(id, { enabled: !!enabled }); - } - - /** - * Validate a table formula - * @param {string} id - Formula ID - * @returns {Object} Validation result with isValid and error - * @public - */ - validateTableFormula(id) { - const formula = this.getTableFormula(id); - if (!formula) { - return { isValid: false, error: __('Formula not found') }; - } - - if (!formula.formula.trim()) { - return { isValid: true, error: null }; - } - - // Use basic formula validation - return validateFormula(formula.formula); - } - - /** - * Get field options for a child table - * @param {string} childDoctype - Child doctype name - * @returns {Promise} List of field options - * @public - */ - async getChildTableFields(childDoctype) { - try { - let meta; - - // Try to get meta from cache - meta = frappe.get_meta(childDoctype); - - if (!meta) { - // If not in cache, fetch it - meta = await frappe.model.with_doctype(childDoctype); - } - - if (!meta || !meta.fields) { - throw new Error(__('Could not fetch metadata for {0}', [childDoctype])); - } - - const fields = []; - - // Add fields from the child DocType - for (const field of meta.fields) { - // Skip certain field types - if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { - continue; - } - - fields.push({ - fieldname: field.fieldname, - label: field.label || field.fieldname, - type: field.fieldtype, - options: field.options, - is_child_table: field.fieldtype === 'Table' - }); - } - - return fields; - - } catch (error) { - console.error(`Error getting fields for child doctype ${childDoctype}:`, error); - return []; - } - } - - /** - * Apply formulas to a table - * @param {Object} doc - Parent document - * @param {string} tableField - Table field name - * @param {Object} [options] - Options for applying formulas - * @param {boolean} [options.silent=false] - Whether to suppress notifications - * @returns {Promise} Result with success status and error if any - * @public - */ - async applyTableFormulas(doc, tableField, options = {}) { - const silent = options.silent || false; - - if (!doc || !tableField || !doc[tableField] || !Array.isArray(doc[tableField])) { - return { success: false, error: __('Invalid table data') }; - } - - try { - // Get formulas for this table - const formulas = this.getFormulasForTable(tableField); - - if (formulas.length === 0) { - return { success: true, message: __('No formulas to apply') }; - } - - // Apply each formula to all rows - for (const formula of formulas) { - if (!formula.enabled) continue; - - for (let rowIdx = 0; rowIdx < doc[tableField].length; rowIdx++) { - await this.applyFormulaToRow(formula, doc, rowIdx); - } - } - - if (!silent) { - showToast(__('Applied {0} formulas to {1} rows', [formulas.length, doc[tableField].length]), 'success'); - } - - return { success: true }; - - } catch (error) { - console.error('Error applying table formulas:', error); - if (!silent) { - showToast(__('Error applying table formulas: {0}', [formatErrorMessage(error)]), 'error'); - } - return { success: false, error: formatErrorMessage(error) }; - } - } - - /** - * Apply a formula to a specific row - * @param {Object} formula - Formula configuration - * @param {Object} doc - Parent document - * @param {number} rowIdx - Row index - * @returns {Promise} Success status - * @private - */ - async applyFormulaToRow(formula, doc, rowIdx) { - try { - if (!formula.enabled || !formula.formula.trim()) { - return true; - } - - const table = doc[formula.table_field]; - if (!table || !Array.isArray(table) || rowIdx >= table.length) { - return false; - } - - const row = table[rowIdx]; - const previousRows = table.slice(0, rowIdx); - const allRows = table; - - // Create evaluation context - const context = { - doc: doc, - row: row, - rowIdx: rowIdx, - previousRows: previousRows, - allRows: allRows, - frappe: frappe - }; - - // Evaluate the formula - const result = await this.evaluateRowFormula(formula.formula, context); - - // Apply the result to the target field - if (result !== undefined) { - row[formula.target_field] = result; - - // If this is a cumulative formula, propagate changes to subsequent rows - if (formula.is_cumulative) { - await this.propagateCumulativeChanges(formula, doc, rowIdx); - } - } - - return true; - - } catch (error) { - console.error(`Error applying formula to row ${rowIdx}:`, error); - return false; - } - } - - /** - * Evaluate a formula in the context of a row - * @param {string} formula - Formula to evaluate - * @param {Object} context - Evaluation context - * @returns {Promise<*>} Evaluation result - * @private - */ - async evaluateRowFormula(formula, context) { - return safeEval(formula, context); - } - - /** - * Propagate cumulative changes to subsequent rows - * @param {Object} formula - Formula configuration - * @param {Object} doc - Parent document - * @param {number} startRowIdx - Starting row index - * @returns {Promise} - * @private - */ - async propagateCumulativeChanges(formula, doc, startRowIdx) { - const table = doc[formula.table_field]; - if (!table || !Array.isArray(table) || startRowIdx >= table.length - 1) { - return; - } - - // Apply formula to all subsequent rows - for (let rowIdx = startRowIdx + 1; rowIdx < table.length; rowIdx++) { - await this.applyFormulaToRow(formula, doc, rowIdx); - } - } - - /** - * Set up event listeners for a form - * @param {frappe.ui.form.Form} form - Frappe form instance - * @public - */ - attachToForm(form) { - if (!form) return; - - // Store the current form - this.currentForm = form; - - // Set up table field change listeners - this.childTableFields.forEach(tableField => { - const fieldName = tableField.fieldname; - - // Get the grid for this table - const grid = form.fields_dict[fieldName]?.grid; - if (!grid) return; - - // Save original grid row functions to extend them - if (!grid._original_refresh) { - grid._original_refresh = grid.refresh; - grid.refresh = () => { - grid._original_refresh(); - this.onGridRefresh(grid, fieldName, form); - }; - } - - if (!grid._original_add_new_row) { - grid._original_add_new_row = grid.add_new_row; - grid.add_new_row = (idx, callback, from_bottom) => { - grid._original_add_new_row(idx, (row) => { - if (callback) callback(row); - this.onRowAdded(grid, row, fieldName, form); - }, from_bottom); - }; - } - - if (!grid._original_setup_columns) { - grid._original_setup_columns = grid.setup_columns; - grid.setup_columns = () => { - grid._original_setup_columns(); - this.setupGridColumns(grid, fieldName, form); - }; - } - }); - - // Set up form save listener - if (!form._tableFormulas_original_save) { - form._tableFormulas_original_save = form.save; - form.save = (save_action, callback, btn, on_error) => { - // Apply 'save' formulas - this.applyAllFormulasOnSave(form).then(() => { - // Call original save method - form._tableFormulas_original_save(save_action, callback, btn, on_error); - }).catch(error => { - console.error('Error applying table formulas on save:', error); - // Still call original save - form._tableFormulas_original_save(save_action, callback, btn, on_error); - }); - }; - } - } - - /** - * Remove event listeners from a form - * @param {frappe.ui.form.Form} form - Frappe form instance - * @public - */ - detachFromForm(form) { - if (!form) return; - - // Restore original grid functions - this.childTableFields.forEach(tableField => { - const fieldName = tableField.fieldname; - const grid = form.fields_dict[fieldName]?.grid; - - if (!grid) return; - - if (grid._original_refresh) { - grid.refresh = grid._original_refresh; - delete grid._original_refresh; - } - - if (grid._original_add_new_row) { - grid.add_new_row = grid._original_add_new_row; - delete grid._original_add_new_row; - } - - if (grid._original_setup_columns) { - grid.setup_columns = grid._original_setup_columns; - delete grid._original_setup_columns; - } - }); - - // Restore original save method - if (form._tableFormulas_original_save) { - form.save = form._tableFormulas_original_save; - delete form._tableFormulas_original_save; - } - - // Clear current form reference - this.currentForm = null; - } - - /** - * Handle grid refresh event - * @param {frappe.ui.form.Grid} grid - Grid instance - * @param {string} fieldName - Table field name - * @param {frappe.ui.form.Form} form - Form instance - * @private - */ - onGridRefresh(grid, fieldName, form) { - // Set up row event handlers - grid.grid_rows.forEach(row => { - this.setupRowEvents(row, fieldName, form); - }); - } - - /** - * Handle row added event - * @param {frappe.ui.form.Grid} grid - Grid instance - * @param {frappe.ui.form.GridRow} row - Grid row instance - * @param {string} fieldName - Table field name - * @param {frappe.ui.form.Form} form - Form instance - * @private - */ - onRowAdded(grid, row, fieldName, form) { - // Set up events for the new row - this.setupRowEvents(row, fieldName, form); - - // Apply 'change' formulas to the new row - setTimeout(() => { - const formulas = this.getFormulasForTable(fieldName).filter(f => - f.enabled && f.apply_on === 'change' - ); - - if (formulas.length > 0) { - const rowIdx = row.doc.idx - 1; - - // Apply each formula - formulas.forEach(formula => { - this.applyFormulaToRow(formula, form.doc, rowIdx) - .then(() => { - // Refresh the row to show updated values - row.refresh(); - }) - .catch(error => { - console.error(`Error applying formula to new row:`, error); - }); - }); - } - }, 100); - } - - /** - * Set up events for a grid row - * @param {frappe.ui.form.GridRow} row - Grid row instance - * @param {string} fieldName - Table field name - * @param {frappe.ui.form.Form} form - Form instance - * @private - */ - setupRowEvents(row, fieldName, form) { - // Skip if already set up - if (row._formulaEventsSetup) return; - - // Get formulas for this table - const formulas = this.getFormulasForTable(fieldName).filter(f => - f.enabled && f.apply_on === 'change' - ); - - if (formulas.length === 0) return; - - // Set up field change handlers - const sourceFields = new Set(); - formulas.forEach(formula => { - formula.source_fields.forEach(field => { - sourceFields.add(field); - }); - - // Also add fields referenced in the formula - const referencedFields = extractReferencedFields(formula.formula, []); - referencedFields.forEach(field => { - if (field.startsWith('row.')) { - const rowField = field.substring(4); - sourceFields.add(rowField); - } - }); - }); - - // For each source field, set up a change handler - sourceFields.forEach(fieldName => { - const field = row.fields_dict[fieldName]; - if (!field) return; - - // If the field already has our wrapper, don't add another one - if (field._formulaChangeHandlerAttached) return; - - // Get the control's change function - const controlProto = Object.getPrototypeOf(field); - if (!controlProto || !controlProto.set_input) return; - - // Save original set_input function - field._original_set_input = field.set_input; - - // Override set_input to catch changes - field.set_input = function() { - const oldValue = field.value; - - // Call original function - field._original_set_input.apply(field, arguments); - - const newValue = field.value; - - // If value changed, trigger our handler - if (oldValue !== newValue) { - row._formula_on_field_change(fieldName, newValue, oldValue); - } - }; - - field._formulaChangeHandlerAttached = true; - }); - - // Add custom field change handler to the row - row._formula_on_field_change = debounce((changedField, newValue, oldValue) => { - const rowIdx = row.doc.idx - 1; - - // Find formulas that depend on this field - const relevantFormulas = formulas.filter(formula => - formula.source_fields.includes(changedField) || - formula.formula.includes(`row.${changedField}`) - ); - - if (relevantFormulas.length === 0) return; - - // Apply each relevant formula - relevantFormulas.forEach(formula => { - this.applyFormulaToRow(formula, form.doc, rowIdx) - .then(() => { - // Refresh the row to show updated values - row.refresh(); - }) - .catch(error => { - console.error(`Error applying formula after field change:`, error); - }); - }); - }, 300); - - row._formulaEventsSetup = true; - } - - /** - * Set up grid columns for better formula UX - * @param {frappe.ui.form.Grid} grid - Grid instance - * @param {string} fieldName - Table field name - * @param {frappe.ui.form.Form} form - Form instance - * @private - */ - setupGridColumns(grid, fieldName, form) { - // Get formulas for this table - const formulas = this.getFormulasForTable(fieldName); - - // Find columns that are calculated by formulas - formulas.forEach(formula => { - if (!formula.enabled) return; - - const colField = grid.grid_columns_by_fieldname[formula.target_field]; - if (!colField) return; - - // Add a class to indicate it's calculated - const headerElement = colField.header_field; - if (headerElement && headerElement.classList) { - headerElement.classList.add('formula-calculated-field'); - - // Add tooltip - headerElement.title = __('This field is calculated by a formula'); - - // Add formula info if grid has a datatable - if (grid.datatable) { - const colIdx = grid.datatable.getColumnIndex(formula.target_field); - if (colIdx !== -1) { - const col = grid.datatable.datamanager.columns[colIdx]; - if (col) { - col.formula = formula.formula; - col.isFormula = true; - } - } - } - } - }); - } - - /** - * Apply all formulas that should run on save - * @param {frappe.ui.form.Form} form - Frappe form instance - * @returns {Promise} - * @private - */ - async applyAllFormulasOnSave(form) { - if (!form) return; - - // Process each table field - for (const tableField of this.childTableFields) { - const fieldName = tableField.fieldname; - - // Get 'save' formulas for this table - const formulas = this.getFormulasForTable(fieldName).filter(f => - f.enabled && f.apply_on === 'save' - ); - - if (formulas.length > 0) { - // Apply formulas to the table - await this.applyTableFormulas(form.doc, fieldName, { silent: true }); - } - } - } - - /** - * Handle formula changes - * @private - */ - handleChange() { - if (typeof this.options.onChange === 'function') { - this.options.onChange(this.tableFormulas); - } - } - - /** - * Import table formulas from JSON - * @param {string|Object} json - JSON string or object - * @returns {boolean} Success status - * @public - */ - importTableFormulas(json) { - try { - let data; - - if (typeof json === 'string') { - data = JSON.parse(json); - } else if (typeof json === 'object' && json !== null) { - data = json; - } else { - throw new Error(__('Invalid import data format')); - } - - if (!Array.isArray(data)) { - throw new Error(__('Import data must be an array')); - } - - // Validate each formula - const validFormulas = data.filter(formula => - typeof formula === 'object' && - formula !== null && - typeof formula.table_field === 'string' && - typeof formula.target_field === 'string' - ); - - if (validFormulas.length === 0) { - throw new Error(__('No valid table formulas found in import data')); - } - - // Replace table formulas - this.tableFormulas = validFormulas.map(formula => { - // Find table field in our list - const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field); - - return { - id: formula.id || `${formula.table_field}_${formula.target_field}_formula`, - name: formula.name || __('Formula for {0}', [formula.target_field]), - table_field: formula.table_field, - target_field: formula.target_field, - source_fields: Array.isArray(formula.source_fields) ? formula.source_fields : [], - formula: formula.formula || '', - child_doctype: tableField ? tableField.options : formula.child_doctype, - enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true, - apply_on: formula.apply_on || 'change', - is_cumulative: !!formula.is_cumulative, - description: formula.description || '' - }; - }); - - this.debouncedOnChange(); - return true; - - } catch (error) { - console.error('Error importing table formulas:', error); - return false; - } - } - - /** - * Export table formulas to JSON - * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON - * @returns {string} JSON string - * @public - */ - exportTableFormulas(prettyPrint = false) { - try { - return JSON.stringify(this.tableFormulas, null, prettyPrint ? 2 : 0); - } catch (error) { - console.error('Error exporting table formulas:', error); - return '[]'; - } - } -} - -export default FormulaEditorTableRows; \ No newline at end of file diff --git a/formula_editor/public/js/triggers.js b/formula_editor/public/js/triggers.js deleted file mode 100644 index 0988c08..0000000 --- a/formula_editor/public/js/triggers.js +++ /dev/null @@ -1,803 +0,0 @@ -/** - * Formula Editor Triggers - * Handles trigger functionality for formula evaluation - */ - -import { - generateUniqueId, - validateFormula, - formatErrorMessage, - debounce, - safeEval, - isEmpty, - deepClone, - showToast -} from './utils'; - -class FormulaEditorTriggers { - /** - * Create a new FormulaEditorTriggers instance - * @param {Object} options - Configuration options - * @param {Object} options.editor - Reference to the main FormulaEditor instance - * @param {Array} [options.triggers=[]] - Initial triggers - * @param {Function} [options.onChange] - Callback for trigger changes - */ - constructor(options) { - this.options = Object.assign({ - editor: null, - triggers: [], - onChange: null - }, options); - - // Validate required options - if (!this.options.editor) { - throw new Error(__('Editor instance is required for FormulaEditorTriggers')); - } - - // Initialize properties - this.editor = this.options.editor; - this.triggers = deepClone(this.options.triggers || []); - this.activeTriggerId = null; - this.triggerFieldsCache = null; - this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); - - // Initialize - this.init(); - } - - /** - * Initialize triggers - * @private - */ - init() { - // Ensure all triggers have proper structure - this.validateTriggers(); - } - - /** - * Validate triggers structure and add missing properties - * @private - */ - validateTriggers() { - if (!Array.isArray(this.triggers)) { - this.triggers = []; - return; - } - - // Process each trigger to ensure it has all required properties - this.triggers = this.triggers.map(trigger => { - // Skip if not an object - if (typeof trigger !== 'object' || trigger === null) { - return null; - } - - // Ensure required properties - return { - id: trigger.id || generateUniqueId('trigger'), - name: trigger.name || __('Unnamed Trigger'), - description: trigger.description || '', - condition: trigger.condition || '', - action: trigger.action || '', - fields: Array.isArray(trigger.fields) ? trigger.fields : [], - enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true, - source_doctype: trigger.source_doctype || this.editor.options.doctype, - target_doctype: trigger.target_doctype || this.editor.options.doctype, - event: trigger.event || 'on_change' // on_change, on_save, on_submit, etc. - }; - }).filter(Boolean); // Remove invalid triggers - } - - /** - * Get all triggers - * @returns {Array} List of triggers - * @public - */ - getTriggers() { - return deepClone(this.triggers); - } - - /** - * Get a trigger by ID - * @param {string} id - Trigger ID - * @returns {Object|null} Trigger object or null if not found - * @public - */ - getTrigger(id) { - const trigger = this.triggers.find(t => t.id === id); - return trigger ? deepClone(trigger) : null; - } - - /** - * Get active trigger - * @returns {Object|null} Active trigger object or null if none active - * @public - */ - getActiveTrigger() { - if (!this.activeTriggerId) return null; - return this.getTrigger(this.activeTriggerId); - } - - /** - * Set active trigger - * @param {string} id - Trigger ID to set as active - * @returns {boolean} Success status - * @public - */ - setActiveTrigger(id) { - const trigger = this.getTrigger(id); - if (!trigger) return false; - - this.activeTriggerId = id; - return true; - } - - /** - * Add a new trigger - * @param {Object} trigger - Trigger data - * @returns {string} New trigger ID - * @public - */ - addTrigger(trigger = {}) { - const newTrigger = { - id: generateUniqueId('trigger'), - name: trigger.name || __('New Trigger'), - description: trigger.description || '', - condition: trigger.condition || '', - action: trigger.action || '', - fields: Array.isArray(trigger.fields) ? deepClone(trigger.fields) : [], - enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true, - source_doctype: trigger.source_doctype || this.editor.options.doctype, - target_doctype: trigger.target_doctype || this.editor.options.doctype, - event: trigger.event || 'on_change' - }; - - this.triggers.push(newTrigger); - this.activeTriggerId = newTrigger.id; - this.debouncedOnChange(); - - return newTrigger.id; - } - - /** - * Update a trigger - * @param {string} id - Trigger ID - * @param {Object} updates - Properties to update - * @returns {boolean} Success status - * @public - */ - updateTrigger(id, updates) { - const index = this.triggers.findIndex(t => t.id === id); - if (index === -1) return false; - - // Clone the trigger - const trigger = deepClone(this.triggers[index]); - - // Apply updates - Object.keys(updates).forEach(key => { - // Handle special properties - if (key === 'fields' && Array.isArray(updates.fields)) { - trigger.fields = deepClone(updates.fields); - } else if (key !== 'id') { // Don't allow changing the ID - trigger[key] = updates[key]; - } - }); - - // Update the trigger - this.triggers[index] = trigger; - this.debouncedOnChange(); - - return true; - } - - /** - * Delete a trigger - * @param {string} id - Trigger ID - * @returns {boolean} Success status - * @public - */ - deleteTrigger(id) { - const index = this.triggers.findIndex(t => t.id === id); - if (index === -1) return false; - - this.triggers.splice(index, 1); - - // Clear active trigger if it was deleted - if (this.activeTriggerId === id) { - this.activeTriggerId = this.triggers.length > 0 ? this.triggers[0].id : null; - } - - this.debouncedOnChange(); - return true; - } - - /** - * Enable/disable a trigger - * @param {string} id - Trigger ID - * @param {boolean} enabled - Whether to enable the trigger - * @returns {boolean} Success status - * @public - */ - setTriggerEnabled(id, enabled) { - return this.updateTrigger(id, { enabled: !!enabled }); - } - - /** - * Reorder triggers - * @param {Array} newOrder - Array of trigger IDs in the new order - * @returns {boolean} Success status - * @public - */ - reorderTriggers(newOrder) { - if (!Array.isArray(newOrder) || newOrder.length !== this.triggers.length) { - return false; - } - - // Check if all IDs exist - const validIds = new Set(this.triggers.map(t => t.id)); - const allIdsExist = newOrder.every(id => validIds.has(id)); - - if (!allIdsExist) return false; - - // Create new ordered array - const ordered = []; - for (const id of newOrder) { - const trigger = this.triggers.find(t => t.id === id); - if (trigger) ordered.push(deepClone(trigger)); - } - - this.triggers = ordered; - this.debouncedOnChange(); - - return true; - } - - /** - * Validate a trigger condition or action - * @param {string} formula - Formula to validate - * @param {string} type - 'condition' or 'action' - * @returns {Object} Validation result with isValid and error - * @public - */ - validateTriggerFormula(formula, type = 'condition') { - if (!formula.trim()) { - return { isValid: true, error: null }; - } - - // Use basic formula validation - return validateFormula(formula); - } - - /** - * Get available fields for a trigger - * @param {string} doctype - DocType to get fields for - * @returns {Array} List of available fields - * @public - */ - async getTriggerFields(doctype) { - // Use cache if available - if (this.triggerFieldsCache && this.triggerFieldsCache[doctype]) { - return deepClone(this.triggerFieldsCache[doctype]); - } - - try { - const fields = []; - let meta; - - // Try to get meta from cache - meta = frappe.get_meta(doctype); - - if (!meta) { - // If not in cache, fetch it - meta = await frappe.model.with_doctype(doctype); - } - - if (!meta || !meta.fields) { - throw new Error(__('Could not fetch metadata for {0}', [doctype])); - } - - // Add fields from the DocType - for (const field of meta.fields) { - // Skip certain field types - if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { - continue; - } - - fields.push({ - fieldname: field.fieldname, - label: field.label || field.fieldname, - type: field.fieldtype, - options: field.options, - is_child_table: field.fieldtype === 'Table' - }); - } - - // Cache the fields - if (!this.triggerFieldsCache) this.triggerFieldsCache = {}; - this.triggerFieldsCache[doctype] = deepClone(fields); - - return fields; - - } catch (error) { - console.error('Error getting trigger fields:', error); - return []; - } - } - - /** - * Test a trigger condition - * @param {string} id - Trigger ID - * @param {Object} [context] - Evaluation context - * @returns {Object} Test result with success, result, error - * @public - */ - testTriggerCondition(id, context = {}) { - const trigger = this.getTrigger(id); - if (!trigger) { - return { success: false, result: false, error: __('Trigger not found') }; - } - - if (!trigger.condition.trim()) { - return { success: true, result: true, error: null }; - } - - try { - // Create evaluation context - const evalContext = { - doc: this.editor.options.doc || {}, - frappe: frappe, - ...context - }; - - // Evaluate the condition - const result = safeEval(trigger.condition, evalContext); - - return { - success: true, - result: !!result, // Convert to boolean - error: null - }; - - } catch (error) { - console.error('Error testing trigger condition:', error); - return { - success: false, - result: false, - error: formatErrorMessage(error) - }; - } - } - - /** - * Test a trigger action - * @param {string} id - Trigger ID - * @param {Object} [context] - Evaluation context - * @returns {Object} Test result with success, result, error - * @public - */ - testTriggerAction(id, context = {}) { - const trigger = this.getTrigger(id); - if (!trigger) { - return { success: false, result: null, error: __('Trigger not found') }; - } - - if (!trigger.action.trim()) { - return { success: true, result: null, error: null }; - } - - try { - // Create evaluation context - const evalContext = { - doc: this.editor.options.doc || {}, - frappe: frappe, - ...context - }; - - // Evaluate the action - const result = safeEval(trigger.action, evalContext); - - return { - success: true, - result: result, - error: null - }; - - } catch (error) { - console.error('Error testing trigger action:', error); - return { - success: false, - result: null, - error: formatErrorMessage(error) - }; - } - } - - /** - * Execute a trigger - * @param {string} id - Trigger ID - * @param {Object} [context] - Evaluation context - * @returns {Object} Execution result with success, actionResult, error - * @public - */ - executeTrigger(id, context = {}) { - const trigger = this.getTrigger(id); - if (!trigger) { - return { success: false, actionResult: null, error: __('Trigger not found') }; - } - - // Skip disabled triggers - if (!trigger.enabled) { - return { success: true, actionResult: null, error: __('Trigger is disabled') }; - } - - // Check condition - const conditionResult = this.testTriggerCondition(id, context); - - if (!conditionResult.success) { - return { - success: false, - actionResult: null, - error: __('Condition evaluation failed: {0}', [conditionResult.error]) - }; - } - - // Skip if condition is not met - if (!conditionResult.result) { - return { - success: true, - actionResult: null, - error: __('Condition not met') - }; - } - - // Execute action - const actionResult = this.testTriggerAction(id, context); - - if (!actionResult.success) { - return { - success: false, - actionResult: null, - error: __('Action execution failed: {0}', [actionResult.error]) - }; - } - - return { - success: true, - actionResult: actionResult.result, - error: null - }; - } - - /** - * Execute all triggers for an event - * @param {string} event - Event name (e.g., 'on_change', 'on_save') - * @param {Object} [context] - Evaluation context - * @returns {Array} Execution results for each trigger - * @public - */ - executeTriggersForEvent(event, context = {}) { - // Filter triggers by event and doctype - const triggers = this.triggers.filter(trigger => - trigger.enabled && - trigger.event === event && - trigger.source_doctype === context.doctype - ); - - const results = []; - - // Execute each trigger - for (const trigger of triggers) { - const result = this.executeTrigger(trigger.id, context); - - results.push({ - triggerId: trigger.id, - triggerName: trigger.name, - ...result - }); - } - - return results; - } - - /** - * Set up event listeners for a form - * @param {frappe.ui.form.Form} form - Frappe form instance - * @public - */ - attachToForm(form) { - if (!form) return; - - // Store the current form - this.currentForm = form; - - // Set up field change listeners - form.fields.forEach(fieldObj => { - const field = fieldObj.df; - - // Skip certain field types - if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { - return; - } - - // Set up change listener - const fieldName = field.fieldname; - if (fieldName) { - const control = form.get_field(fieldName); - if (control && control.$input) { - // Create debounced handler for each field - const debouncedHandler = debounce(() => { - this.handleFieldChange(fieldName, form); - }, 300); - - // Save the original change event if exists - if (control.df._original_change) return; - control.df._original_change = control.df.change; - - // Set the new change event - control.df.change = () => { - // Call original event if exists - if (typeof control.df._original_change === 'function') { - control.df._original_change(); - } - - // Call the debounced handler - debouncedHandler(); - }; - } - } - }); - - // Set up form save listener - if (!form._original_save) { - form._original_save = form.save; - form.save = (save_action, callback, btn, on_error) => { - // Execute 'on_save' triggers - this.executeTriggersForEvent('on_save', { - doc: form.doc, - doctype: form.doctype, - form: form - }); - - // Call original save method - form._original_save(save_action, callback, btn, on_error); - }; - } - - // Set up form submit listener - if (!form._original_savesubmit) { - form._original_savesubmit = form.savesubmit; - form.savesubmit = (callback, btn, on_error) => { - // Execute 'on_submit' triggers - this.executeTriggersForEvent('on_submit', { - doc: form.doc, - doctype: form.doctype, - form: form - }); - - // Call original savesubmit method - form._original_savesubmit(callback, btn, on_error); - }; - } - } - - /** - * Remove event listeners from a form - * @param {frappe.ui.form.Form} form - Frappe form instance - * @public - */ - detachFromForm(form) { - if (!form) return; - - // Restore original field change events - form.fields.forEach(fieldObj => { - const field = fieldObj.df; - const fieldName = field.fieldname; - - if (fieldName) { - const control = form.get_field(fieldName); - if (control && control.df._original_change) { - control.df.change = control.df._original_change; - delete control.df._original_change; - } - } - }); - - // Restore original save method - if (form._original_save) { - form.save = form._original_save; - delete form._original_save; - } - - // Restore original savesubmit method - if (form._original_savesubmit) { - form.savesubmit = form._original_savesubmit; - delete form._original_savesubmit; - } - - // Clear current form reference - this.currentForm = null; - } - - /** - * Handle field change event - * @param {string} fieldName - Name of the changed field - * @param {frappe.ui.form.Form} form - Frappe form instance - * @private - */ - handleFieldChange(fieldName, form) { - if (!form) return; - - // Execute 'on_change' triggers with field context - const results = this.executeTriggersForEvent('on_change', { - doc: form.doc, - doctype: form.doctype, - form: form, - field: fieldName, - value: form.doc[fieldName] - }); - - // Refresh form if any trigger executed successfully - const shouldRefresh = results.some(result => result.success); - if (shouldRefresh) { - // Delay refresh to avoid conflicts - setTimeout(() => { - form.refresh(); - }, 100); - } - } - - /** - * Handle trigger changes - * @private - */ - handleChange() { - if (typeof this.options.onChange === 'function') { - this.options.onChange(this.triggers); - } - } - - /** - * Import triggers from JSON - * @param {string|Object} json - JSON string or object - * @returns {boolean} Success status - * @public - */ - importTriggers(json) { - try { - let data; - - if (typeof json === 'string') { - data = JSON.parse(json); - } else if (typeof json === 'object' && json !== null) { - data = json; - } else { - throw new Error(__('Invalid import data format')); - } - - if (!Array.isArray(data)) { - throw new Error(__('Import data must be an array')); - } - - // Validate each trigger - const validTriggers = data.filter(trigger => - typeof trigger === 'object' && - trigger !== null && - typeof trigger.name === 'string' - ); - - if (validTriggers.length === 0) { - throw new Error(__('No valid triggers found in import data')); - } - - // Replace triggers - this.triggers = validTriggers.map(trigger => ({ - id: trigger.id || generateUniqueId('trigger'), - name: trigger.name, - description: trigger.description || '', - condition: trigger.condition || '', - action: trigger.action || '', - fields: Array.isArray(trigger.fields) ? trigger.fields : [], - enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true, - source_doctype: trigger.source_doctype || this.editor.options.doctype, - target_doctype: trigger.target_doctype || this.editor.options.doctype, - event: trigger.event || 'on_change' - })); - - // Set first trigger as active - if (this.triggers.length > 0) { - this.activeTriggerId = this.triggers[0].id; - } else { - this.activeTriggerId = null; - } - - this.debouncedOnChange(); - return true; - - } catch (error) { - console.error('Error importing triggers:', error); - return false; - } - } - - /** - * Export triggers to JSON - * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON - * @returns {string} JSON string - * @public - */ - exportTriggers(prettyPrint = false) { - try { - return JSON.stringify(this.triggers, null, prettyPrint ? 2 : 0); - } catch (error) { - console.error('Error exporting triggers:', error); - return '[]'; - } - } - - /** - * Create default triggers for a form - * @param {string} doctype - DocType to create triggers for - * @returns {boolean} Success status - * @public - */ - async createDefaultTriggers(doctype) { - try { - if (this.triggers.length > 0) { - const confirmed = await new Promise(resolve => { - frappe.confirm( - __('This will replace all existing triggers. Continue?'), - () => resolve(true), - () => resolve(false) - ); - }); - - if (!confirmed) return false; - } - - // Get fields - const fields = await this.getTriggerFields(doctype); - - // Create default triggers for numeric fields - const numericFields = fields.filter(f => - ['Int', 'Float', 'Currency', 'Percent'].includes(f.type) - ); - - const defaultTriggers = []; - - for (const field of numericFields) { - // Skip certain fields like ID, name, etc. - if (['name', 'owner', 'creation', 'modified', 'modified_by', 'idx'].includes(field.fieldname)) { - continue; - } - - defaultTriggers.push({ - name: __('Validate {0}', [field.label || field.fieldname]), - description: __('Validate {0} is not negative', [field.label || field.fieldname]), - condition: `doc.${field.fieldname} < 0`, - action: `frappe.throw(__("${field.label || field.fieldname} cannot be negative"));`, - fields: [field.fieldname], - enabled: true, - source_doctype: doctype, - target_doctype: doctype, - event: 'on_change' - }); - } - - // Import the default triggers - if (defaultTriggers.length > 0) { - this.importTriggers(defaultTriggers); - showToast(__('Created {0} default triggers', [defaultTriggers.length]), 'success'); - return true; - } else { - showToast(__('No suitable fields found for default triggers'), 'warning'); - return false; - } - - } catch (error) { - console.error('Error creating default triggers:', error); - showToast(__('Error creating default triggers'), 'error'); - return false; - } - } -} - -export default FormulaEditorTriggers; \ No newline at end of file diff --git a/formula_editor/public/js/ui.js b/formula_editor/public/js/ui.js deleted file mode 100644 index 61d2e30..0000000 --- a/formula_editor/public/js/ui.js +++ /dev/null @@ -1,1204 +0,0 @@ -/** - * Formula Editor UI - * Handles the user interface components for the Formula Editor - */ - -import { - generateUniqueId, - addClass, - removeClass, - toggleClass, - debounce, - fieldToLabel, - formatErrorMessage, - showToast -} from './utils'; - -class FormulaEditorUI { - /** - * Create a new FormulaEditorUI instance - * @param {Object} options - Configuration options - * @param {FormulaEditor} options.editor - Reference to the main FormulaEditor instance - * @param {HTMLElement} options.container - Container element for the UI - * @param {boolean} [options.showFieldsPanel=true] - Whether to show the fields panel - * @param {boolean} [options.showFunctionsPanel=true] - Whether to show the functions panel - * @param {boolean} [options.readOnly=false] - Whether the editor is read-only - * @param {string} [options.height='300px'] - Height of the editor - */ - constructor(options) { - this.options = Object.assign({ - editor: null, - container: null, - showFieldsPanel: true, - showFunctionsPanel: true, - readOnly: false, - height: '300px', - mode: 'formula' // 'formula', 'trigger', 'report' - }, options); - - // Validate required options - if (!this.options.editor) { - throw new Error(__('Editor instance is required for FormulaEditorUI')); - } - - if (!this.options.container) { - throw new Error(__('Container element is required for FormulaEditorUI')); - } - - // Initialize properties - this.editor = this.options.editor; - this.container = this.options.container; - this.uiId = generateUniqueId('formula-ui'); - this.editorElement = null; - this.fieldsPanelElement = null; - this.functionsPanelElement = null; - this.toolbarElement = null; - this.errorElement = null; - this.aceEditor = null; - this.debouncedResize = debounce(this.handleResize.bind(this), 100); - - // Build the UI - this.build(); - } - - /** - * Build the UI components - * @private - */ - build() { - // Clear container - this.container.innerHTML = ''; - this.container.className = 'formula-editor-container'; - - if (this.options.mode === 'trigger') { - addClass(this.container, 'trigger-mode'); - } else if (this.options.mode === 'report') { - addClass(this.container, 'report-mode'); - } - - // Create main layout - this.createLayout(); - - // Create components - this.createToolbar(); - this.createEditor(); - this.createFieldsPanel(); - this.createFunctionsPanel(); - this.createErrorPanel(); - - // Set initial state - this.setReadOnly(this.options.readOnly); - - // Initialize Ace editor if available - this.initAceEditor(); - - // Bind events - this.bindEvents(); - } - - /** - * Create the main layout structure - * @private - */ - createLayout() { - const wrapper = document.createElement('div'); - wrapper.className = 'formula-editor-wrapper'; - - // Top section with toolbar - const topSection = document.createElement('div'); - topSection.className = 'formula-editor-top'; - - // Main section with editor and panels - const mainSection = document.createElement('div'); - mainSection.className = 'formula-editor-main'; - - // Editor section - const editorSection = document.createElement('div'); - editorSection.className = 'formula-editor-section formula-editor-code-section'; - - // Panels section - const panelsSection = document.createElement('div'); - panelsSection.className = 'formula-editor-section formula-editor-panels-section'; - - // Bottom section with error panel - const bottomSection = document.createElement('div'); - bottomSection.className = 'formula-editor-bottom'; - - // Assemble the structure - mainSection.appendChild(editorSection); - mainSection.appendChild(panelsSection); - - wrapper.appendChild(topSection); - wrapper.appendChild(mainSection); - wrapper.appendChild(bottomSection); - - this.container.appendChild(wrapper); - - // Store references - this.wrapper = wrapper; - this.topSection = topSection; - this.mainSection = mainSection; - this.editorSection = editorSection; - this.panelsSection = panelsSection; - this.bottomSection = bottomSection; - } - - /** - * Create the toolbar - * @private - */ - createToolbar() { - const toolbar = document.createElement('div'); - toolbar.className = 'formula-editor-toolbar'; - - // Add buttons - - // Validate button - const validateBtn = this.createToolbarButton('validate', __('Validate'), 'small btn-default'); - validateBtn.innerHTML = ' ' + __('Validate'); - - // Help button - const helpBtn = this.createToolbarButton('help', __('Help'), 'small btn-default icon-btn'); - helpBtn.innerHTML = ''; - - // Settings button - const settingsBtn = this.createToolbarButton('settings', __('Settings'), 'small btn-default icon-btn'); - settingsBtn.innerHTML = ''; - - toolbar.appendChild(validateBtn); - toolbar.appendChild(helpBtn); - toolbar.appendChild(settingsBtn); - - // Add to top section - this.topSection.appendChild(toolbar); - this.toolbarElement = toolbar; - } - - /** - * Create a toolbar button - * @param {string} id - Button ID - * @param {string} title - Button title - * @param {string} className - Button CSS classes - * @returns {HTMLElement} Button element - * @private - */ - createToolbarButton(id, title, className = '') { - const button = document.createElement('button'); - button.type = 'button'; - button.dataset.action = id; - button.title = title; - button.className = `btn ${className}`; - - return button; - } - - /** - * Create the code editor area - * @private - */ - createEditor() { - const editorContainer = document.createElement('div'); - editorContainer.className = 'formula-editor-code-container'; - - // Create pre element for Ace editor - const editorElement = document.createElement('pre'); - editorElement.id = `${this.uiId}-code`; - editorElement.className = 'formula-editor-code'; - - // Fallback textarea (used if Ace is not available) - const fallbackTextarea = document.createElement('textarea'); - fallbackTextarea.className = 'formula-editor-textarea'; - fallbackTextarea.placeholder = __('Enter your formula here...'); - - editorContainer.appendChild(editorElement); - editorContainer.appendChild(fallbackTextarea); - - this.editorSection.appendChild(editorContainer); - - this.editorElement = editorElement; - this.fallbackTextarea = fallbackTextarea; - this.editorContainer = editorContainer; - - // Set height - editorContainer.style.height = this.options.height; - } - - /** - * Create the fields panel - * @private - */ - createFieldsPanel() { - if (!this.options.showFieldsPanel) return; - - const fieldsPanel = document.createElement('div'); - fieldsPanel.className = 'formula-editor-panel formula-editor-fields-panel'; - - // Panel header - const header = document.createElement('div'); - header.className = 'panel-header'; - header.innerHTML = `
${__('Available Fields')}
`; - - // Search input - const searchContainer = document.createElement('div'); - searchContainer.className = 'panel-search'; - - const searchInput = document.createElement('input'); - searchInput.type = 'text'; - searchInput.className = 'form-control'; - searchInput.placeholder = __('Search fields...'); - - searchContainer.appendChild(searchInput); - - // Fields list - const fieldsList = document.createElement('div'); - fieldsList.className = 'panel-content fields-list'; - - // Assemble the panel - fieldsPanel.appendChild(header); - fieldsPanel.appendChild(searchContainer); - fieldsPanel.appendChild(fieldsList); - - this.panelsSection.appendChild(fieldsPanel); - - // Store references - this.fieldsPanelElement = fieldsPanel; - this.fieldsListElement = fieldsList; - this.fieldsSearchInput = searchInput; - - // Populate fields - this.populateFields(); - } - - /** - * Create the functions panel - * @private - */ - createFunctionsPanel() { - if (!this.options.showFunctionsPanel) return; - - const functionsPanel = document.createElement('div'); - functionsPanel.className = 'formula-editor-panel formula-editor-functions-panel'; - - // Panel header - const header = document.createElement('div'); - header.className = 'panel-header'; - header.innerHTML = `
${__('Functions')}
`; - - // Search input - const searchContainer = document.createElement('div'); - searchContainer.className = 'panel-search'; - - const searchInput = document.createElement('input'); - searchInput.type = 'text'; - searchInput.className = 'form-control'; - searchInput.placeholder = __('Search functions...'); - - searchContainer.appendChild(searchInput); - - // Functions categories - const categoriesList = document.createElement('div'); - categoriesList.className = 'panel-content categories-list'; - - // Functions list - const functionsList = document.createElement('div'); - functionsList.className = 'panel-content functions-list'; - - // Assemble the panel - functionsPanel.appendChild(header); - functionsPanel.appendChild(searchContainer); - functionsPanel.appendChild(categoriesList); - functionsPanel.appendChild(functionsList); - - this.panelsSection.appendChild(functionsPanel); - - // Store references - this.functionsPanelElement = functionsPanel; - this.functionsListElement = functionsList; - this.categoriesListElement = categoriesList; - this.functionsSearchInput = searchInput; - - // Populate functions - this.populateFunctionCategories(); - } - - /** - * Create the error panel - * @private - */ - createErrorPanel() { - const errorPanel = document.createElement('div'); - errorPanel.className = 'formula-editor-error-panel hidden'; - - const errorIcon = document.createElement('i'); - errorIcon.className = 'fa fa-exclamation-triangle'; - - const errorMessage = document.createElement('span'); - errorMessage.className = 'error-message'; - - errorPanel.appendChild(errorIcon); - errorPanel.appendChild(errorMessage); - - this.bottomSection.appendChild(errorPanel); - - this.errorElement = errorPanel; - this.errorMessageElement = errorMessage; - } - - /** - * Initialize Ace editor if available - * @private - */ - initAceEditor() { - // Check if Ace is available - if (typeof ace === 'undefined') { - console.warn('Ace editor not available, using fallback textarea'); - addClass(this.editorElement, 'hidden'); - removeClass(this.fallbackTextarea, 'hidden'); - return; - } - - // Hide fallback textarea - addClass(this.fallbackTextarea, 'hidden'); - removeClass(this.editorElement, 'hidden'); - - // Initialize Ace editor - try { - this.aceEditor = ace.edit(this.editorElement.id); - this.aceEditor.setTheme('ace/theme/chrome'); - this.aceEditor.session.setMode('ace/mode/javascript'); - this.aceEditor.setShowPrintMargin(false); - this.aceEditor.setHighlightActiveLine(true); - this.aceEditor.setFontSize('12px'); - - // Set editor options - this.aceEditor.setOptions({ - enableBasicAutocompletion: true, - enableSnippets: true, - enableLiveAutocompletion: true - }); - - // Set initial value if available - const formula = this.editor.getValue(); - if (formula) { - this.aceEditor.setValue(formula, -1); - } - - // Bind editor events - this.aceEditor.on('change', () => { - const value = this.aceEditor.getValue(); - // Update the formula in the main editor - this.editor.setValue(value); - }); - - } catch (error) { - console.error('Error initializing Ace editor:', error); - // Fall back to textarea - addClass(this.editorElement, 'hidden'); - removeClass(this.fallbackTextarea, 'hidden'); - } - } - - /** - * Populate the fields panel with available fields - * @private - */ - populateFields() { - if (!this.fieldsListElement) return; - - // Clear current fields - this.fieldsListElement.innerHTML = ''; - - // Get fields from the editor - const fields = this.editor.fields || []; - - if (fields.length === 0) { - const noFields = document.createElement('div'); - noFields.className = 'empty-state'; - noFields.textContent = __('No fields available'); - this.fieldsListElement.appendChild(noFields); - return; - } - - // Create field groups - const fieldGroups = this.groupFields(fields); - - // Create group elements - for (const [group, groupFields] of Object.entries(fieldGroups)) { - const groupElement = this.createFieldGroup(group, groupFields); - this.fieldsListElement.appendChild(groupElement); - } - } - - /** - * Group fields by type/category - * @param {Array} fields - Fields to group - * @returns {Object} Grouped fields - * @private - */ - groupFields(fields) { - const groups = { - 'Standard': [], - 'Link & Select': [], - 'Text': [], - 'Numeric': [], - 'Date & Time': [], - 'Checkbox': [], - 'Tables': [], - 'Others': [] - }; - - // Group fields by type - for (const field of fields) { - if (field.standard) { - groups['Standard'].push(field); - continue; - } - - switch (field.type) { - case 'Link': - case 'Select': - case 'Dynamic Link': - groups['Link & Select'].push(field); - break; - case 'Data': - case 'Small Text': - case 'Long Text': - case 'Text Editor': - case 'Code': - groups['Text'].push(field); - break; - case 'Int': - case 'Float': - case 'Currency': - case 'Percent': - groups['Numeric'].push(field); - break; - case 'Date': - case 'Datetime': - case 'Time': - groups['Date & Time'].push(field); - break; - case 'Check': - groups['Checkbox'].push(field); - break; - case 'Table': - groups['Tables'].push(field); - break; - default: - groups['Others'].push(field); - } - } - - // Remove empty groups - for (const group in groups) { - if (groups[group].length === 0) { - delete groups[group]; - } - } - - return groups; - } - - /** - * Create a field group element - * @param {string} groupName - Group name - * @param {Array} fields - Fields in the group - * @returns {HTMLElement} Group element - * @private - */ - createFieldGroup(groupName, fields) { - const groupElement = document.createElement('div'); - groupElement.className = 'field-group'; - - // Group header - const header = document.createElement('div'); - header.className = 'group-header'; - header.innerHTML = ` - ${groupName} - ${fields.length} - - `; - - // Group content - const content = document.createElement('div'); - content.className = 'group-content'; - - // Add fields to the group - for (const field of fields) { - const fieldElement = this.createFieldElement(field); - content.appendChild(fieldElement); - } - - groupElement.appendChild(header); - groupElement.appendChild(content); - - // Toggle group on click - header.addEventListener('click', () => { - toggleClass(groupElement, 'collapsed'); - - // Toggle icon - const icon = header.querySelector('i'); - if (icon) { - icon.className = groupElement.classList.contains('collapsed') - ? 'fa fa-chevron-right' - : 'fa fa-chevron-down'; - } - }); - - return groupElement; - } - - /** - * Create a field element - * @param {Object} field - Field data - * @returns {HTMLElement} Field element - * @private - */ - createFieldElement(field) { - const fieldElement = document.createElement('div'); - fieldElement.className = 'field-item'; - fieldElement.dataset.fieldname = field.fieldname; - fieldElement.dataset.fieldtype = field.type; - - // Field icon based on type - let iconClass = 'fa-circle-o'; - - switch (field.type) { - case 'Link': - case 'Dynamic Link': - iconClass = 'fa-link'; - break; - case 'Select': - iconClass = 'fa-list'; - break; - case 'Table': - iconClass = 'fa-table'; - break; - case 'Int': - case 'Float': - case 'Currency': - case 'Percent': - iconClass = 'fa-calculator'; - break; - case 'Date': - case 'Datetime': - iconClass = 'fa-calendar'; - break; - case 'Check': - iconClass = 'fa-check-square-o'; - break; - case 'Data': - case 'Small Text': - case 'Long Text': - case 'Text Editor': - iconClass = 'fa-font'; - break; - case 'Code': - iconClass = 'fa-code'; - break; - case 'Object': - iconClass = 'fa-cubes'; - break; - } - - fieldElement.innerHTML = ` - - ${field.label || fieldToLabel(field.fieldname)} - ${field.type} - `; - - // Make field draggable - fieldElement.draggable = true; - fieldElement.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', field.fieldname); - e.dataTransfer.effectAllowed = 'copy'; - }); - - // Double click to insert field - fieldElement.addEventListener('dblclick', () => { - this.insertField(field); - }); - - return fieldElement; - } - - /** - * Populate function categories - * @private - */ - populateFunctionCategories() { - if (!this.categoriesListElement) return; - - // Clear current categories - this.categoriesListElement.innerHTML = ''; - - // Function categories - const categories = this.getFunctionCategories(); - - // Create category elements - for (const category of categories) { - const categoryElement = document.createElement('div'); - categoryElement.className = 'category-item'; - categoryElement.dataset.category = category.id; - - categoryElement.innerHTML = ` - ${category.name} - `; - - // Select category on click - categoryElement.addEventListener('click', () => { - // Deselect all categories - const items = this.categoriesListElement.querySelectorAll('.category-item'); - items.forEach(item => removeClass(item, 'selected')); - - // Select this category - addClass(categoryElement, 'selected'); - - // Show functions for this category - this.populateFunctions(category.id); - }); - - this.categoriesListElement.appendChild(categoryElement); - } - - // Select first category by default - if (categories.length > 0) { - const firstCategory = this.categoriesListElement.querySelector('.category-item'); - if (firstCategory) { - firstCategory.click(); - } - } - } - - /** - * Get function categories - * @returns {Array} Function categories - * @private - */ - getFunctionCategories() { - return [ - { id: 'math', name: __('Math') }, - { id: 'text', name: __('Text') }, - { id: 'date', name: __('Date & Time') }, - { id: 'logical', name: __('Logical') }, - { id: 'array', name: __('Array & Table') }, - { id: 'financial', name: __('Financial') }, - { id: 'conversion', name: __('Conversion') }, - { id: 'utility', name: __('Utility') } - ]; - } - - /** - * Populate functions for a category - * @param {string} categoryId - Category ID - * @private - */ - populateFunctions(categoryId) { - if (!this.functionsListElement) return; - - // Clear current functions - this.functionsListElement.innerHTML = ''; - - // Get functions for the category - const functions = this.getFunctionsForCategory(categoryId); - - if (functions.length === 0) { - const noFunctions = document.createElement('div'); - noFunctions.className = 'empty-state'; - noFunctions.textContent = __('No functions available'); - this.functionsListElement.appendChild(noFunctions); - return; - } - - // Create function elements - for (const func of functions) { - const funcElement = this.createFunctionElement(func); - this.functionsListElement.appendChild(funcElement); - } - } - - /** - * Get functions for a category - * @param {string} categoryId - Category ID - * @returns {Array} Functions for the category - * @private - */ - getFunctionsForCategory(categoryId) { - // Definition of available functions by category - const functionsByCategory = { - 'math': [ - { name: 'abs', syntax: 'abs(number)', description: __('Returns the absolute value of a number') }, - { name: 'round', syntax: 'round(number, precision)', description: __('Rounds a number to a specified precision') }, - { name: 'floor', syntax: 'floor(number)', description: __('Rounds a number down to the nearest integer') }, - { name: 'ceil', syntax: 'ceil(number)', description: __('Rounds a number up to the nearest integer') }, - { name: 'min', syntax: 'min(number1, number2, ...)', description: __('Returns the smallest number in a set of numbers') }, - { name: 'max', syntax: 'max(number1, number2, ...)', description: __('Returns the largest number in a set of numbers') }, - { name: 'pow', syntax: 'pow(base, exponent)', description: __('Returns a number raised to a power') }, - { name: 'sqrt', syntax: 'sqrt(number)', description: __('Returns the square root of a number') } - ], - 'text': [ - { name: 'concat', syntax: 'concat(text1, text2, ...)', description: __('Joins multiple strings together') }, - { name: 'includes', syntax: 'text.includes(search)', description: __('Checks if a string contains another string') }, - { name: 'indexOf', syntax: 'text.indexOf(search)', description: __('Returns the position of a substring in a string') }, - { name: 'toLowerCase', syntax: 'text.toLowerCase()', description: __('Converts a string to lowercase') }, - { name: 'toUpperCase', syntax: 'text.toUpperCase()', description: __('Converts a string to uppercase') }, - { name: 'trim', syntax: 'text.trim()', description: __('Removes whitespace from both ends of a string') }, - { name: 'replace', syntax: 'text.replace(search, replacement)', description: __('Replaces occurrences of a substring with another string') }, - { name: 'substring', syntax: 'text.substring(start, end)', description: __('Extracts a portion of a string') } - ], - 'date': [ - { name: 'now', syntax: 'frappe.datetime.now()', description: __('Returns the current date and time') }, - { name: 'today', syntax: 'frappe.datetime.get_today()', description: __('Returns the current date') }, - { name: 'addDays', syntax: 'frappe.datetime.add_days(date, days)', description: __('Adds a specified number of days to a date') }, - { name: 'addMonths', syntax: 'frappe.datetime.add_months(date, months)', description: __('Adds a specified number of months to a date') }, - { name: 'formatDate', syntax: 'frappe.datetime.str_to_user(date)', description: __('Formats a date according to user settings') }, - { name: 'getDateDiff', syntax: 'frappe.datetime.get_diff(date1, date2)', description: __('Returns the difference in days between two dates') } - ], - 'logical': [ - { name: 'if', syntax: 'condition ? trueValue : falseValue', description: __('Returns one value if a condition is true and another if it is false') }, - { name: 'and', syntax: 'value1 && value2', description: __('Returns true if both operands are true') }, - { name: 'or', syntax: 'value1 || value2', description: __('Returns true if either operand is true') }, - { name: 'not', syntax: '!value', description: __('Returns the opposite of a boolean value') } - ], - 'array': [ - { name: 'length', syntax: 'array.length', description: __('Returns the number of elements in an array') }, - { name: 'map', syntax: 'array.map(function)', description: __('Creates a new array with the results of calling a function on every element') }, - { name: 'filter', syntax: 'array.filter(function)', description: __('Creates a new array with elements that pass a test') }, - { name: 'find', syntax: 'array.find(function)', description: __('Returns the first element that passes a test') }, - { name: 'reduce', syntax: 'array.reduce(function, initialValue)', description: __('Reduces an array to a single value') }, - { name: 'forEach', syntax: 'array.forEach(function)', description: __('Calls a function for each element in an array') } - ], - 'financial': [ - { name: 'flt', syntax: 'flt(value, precision)', description: __('Converts a value to a float with specified precision') }, - { name: 'fmt_money', syntax: 'format_currency(value, currency)', description: __('Formats a number as currency') }, - { name: 'cint', syntax: 'cint(value)', description: __('Converts a value to an integer') } - ], - 'conversion': [ - { name: 'parseInt', syntax: 'parseInt(string, radix)', description: __('Parses a string and returns an integer') }, - { name: 'parseFloat', syntax: 'parseFloat(string)', description: __('Parses a string and returns a floating point number') }, - { name: 'toString', syntax: 'value.toString()', description: __('Converts a value to a string') }, - { name: 'Number', syntax: 'Number(value)', description: __('Converts a value to a number') }, - { name: 'Boolean', syntax: 'Boolean(value)', description: __('Converts a value to a boolean') } - ], - 'utility': [ - { name: 'isNaN', syntax: 'isNaN(value)', description: __('Determines whether a value is NaN') }, - { name: 'isFinite', syntax: 'isFinite(value)', description: __('Determines whether a value is a finite number') }, - { name: 'typeof', syntax: 'typeof value', description: __('Returns the type of a value') }, - { name: 'JSON.stringify', syntax: 'JSON.stringify(value)', description: __('Converts a JavaScript object to a JSON string') }, - { name: 'JSON.parse', syntax: 'JSON.parse(string)', description: __('Parses a JSON string') } - ] - }; - - return functionsByCategory[categoryId] || []; - } - - /** - * Create a function element - * @param {Object} func - Function data - * @returns {HTMLElement} Function element - * @private - */ - createFunctionElement(func) { - const funcElement = document.createElement('div'); - funcElement.className = 'function-item'; - funcElement.dataset.function = func.name; - - funcElement.innerHTML = ` -
- ${func.name} - ${func.syntax} -
-
${func.description}
- `; - - // Insert function on double click - funcElement.addEventListener('dblclick', () => { - this.insertFunction(func); - }); - - return funcElement; - } - - /** - * Insert a field into the editor - * @param {Object} field - Field to insert - * @private - */ - insertField(field) { - if (!field || !field.fieldname) return; - - let insertText = field.fieldname; - - // For child table fields, include the table name - if (field.parent_table) { - insertText = `${field.parent_table}.${field.fieldname}`; - } - - this.insertAtCursor(insertText); - } - - /** - * Insert a function into the editor - * @param {Object} func - Function to insert - * @private - */ - insertFunction(func) { - if (!func || !func.name) return; - - // Extract the function name and parameters - const match = func.syntax.match(/(\w+)\((.*)\)/); - - if (match) { - const functionName = match[1]; - const parameters = match[2].split(',').map(p => p.trim()); - - // Create function text with cursor positioned between parentheses - let insertText = functionName + '('; - - if (parameters.length === 1 && parameters[0] === '') { - // No parameters - insertText += ')'; - } else { - // Add parameters as placeholders - insertText += parameters.join(', ') + ')'; - } - - this.insertAtCursor(insertText); - } else { - // Just insert the function name if syntax parsing fails - this.insertAtCursor(func.name); - } - } - - /** - * Insert text at the cursor position - * @param {string} text - Text to insert - * @private - */ - insertAtCursor(text) { - if (this.aceEditor) { - // Insert at Ace editor cursor - this.aceEditor.insert(text); - this.aceEditor.focus(); - } else { - // Insert at textarea cursor - const textarea = this.fallbackTextarea; - const startPos = textarea.selectionStart; - const endPos = textarea.selectionEnd; - const scrollTop = textarea.scrollTop; - - textarea.value = textarea.value.substring(0, startPos) + text + textarea.value.substring(endPos); - - // Move cursor after the inserted text - textarea.selectionStart = startPos + text.length; - textarea.selectionEnd = startPos + text.length; - - // Preserve scroll position - textarea.scrollTop = scrollTop; - - // Update the editor value - const event = new Event('input', { bubbles: true }); - textarea.dispatchEvent(event); - - textarea.focus(); - } - } - - /** - * Set editor value - * @param {string} value - New value - * @public - */ - setValue(value) { - if (this.aceEditor) { - this.aceEditor.setValue(value || '', -1); - } else if (this.fallbackTextarea) { - this.fallbackTextarea.value = value || ''; - } - } - - /** - * Get editor value - * @returns {string} Editor value - * @public - */ - getValue() { - if (this.aceEditor) { - return this.aceEditor.getValue(); - } else if (this.fallbackTextarea) { - return this.fallbackTextarea.value; - } - - return ''; - } - - /** - * Set editor to read-only mode - * @param {boolean} readOnly - Whether editor is read-only - * @public - */ - setReadOnly(readOnly) { - if (this.aceEditor) { - this.aceEditor.setReadOnly(readOnly); - } else if (this.fallbackTextarea) { - this.fallbackTextarea.readOnly = readOnly; - } - - toggleClass(this.container, 'read-only', readOnly); - } - - /** - * Show error message - * @param {string} message - Error message - * @public - */ - showError(message) { - if (!this.errorElement || !this.errorMessageElement) return; - - this.errorMessageElement.textContent = formatErrorMessage(message); - removeClass(this.errorElement, 'hidden'); - addClass(this.container, 'has-error'); - } - - /** - * Hide error message - * @public - */ - hideError() { - if (!this.errorElement) return; - - addClass(this.errorElement, 'hidden'); - removeClass(this.container, 'has-error'); - } - - /** - * Bind UI events - * @private - */ - bindEvents() { - // Window resize event - window.addEventListener('resize', this.debouncedResize); - - // Toolbar button events - if (this.toolbarElement) { - this.toolbarElement.addEventListener('click', (event) => { - const button = event.target.closest('button'); - if (!button) return; - - const action = button.dataset.action; - if (!action) return; - - // Handle button actions - switch (action) { - case 'validate': - this.validateFormula(); - break; - case 'help': - this.showHelp(); - break; - case 'settings': - this.showSettings(); - break; - } - }); - } - - // Fields search event - if (this.fieldsSearchInput) { - this.fieldsSearchInput.addEventListener('input', () => { - this.searchFields(this.fieldsSearchInput.value); - }); - } - - // Functions search event - if (this.functionsSearchInput) { - this.functionsSearchInput.addEventListener('input', () => { - this.searchFunctions(this.functionsSearchInput.value); - }); - } - - // Fallback textarea events (if Ace is not available) - if (this.fallbackTextarea) { - this.fallbackTextarea.addEventListener('input', () => { - // Update the main editor value - const value = this.fallbackTextarea.value; - this.editor.setValue(value); - }); - } - } - - /** - * Handle window resize - * @private - */ - handleResize() { - if (this.aceEditor) { - this.aceEditor.resize(); - } - } - - /** - * Validate the current formula - * @private - */ - validateFormula() { - const formula = this.getValue(); - const isValid = this.editor.validate(); - - if (isValid) { - showToast(__('Formula is valid'), 'success'); - } - } - - /** - * Show help dialog - * @private - */ - showHelp() { - frappe.help.show_video( - 'https://www.youtube.com/embed/exampleVideo', // Placeholder URL - __('Formula Editor Help') - ); - } - - /** - * Show settings dialog - * @private - */ - showSettings() { - // Implementation will vary based on requirements - console.log('Show settings dialog'); - } - - /** - * Search fields - * @param {string} query - Search query - * @private - */ - searchFields(query) { - if (!query) { - // Show all fields - removeClass(this.fieldsListElement.querySelectorAll('.field-item'), 'hidden'); - removeClass(this.fieldsListElement.querySelectorAll('.field-group'), 'hidden'); - return; - } - - query = query.toLowerCase(); - - // Hide/show field items based on query - const fieldItems = this.fieldsListElement.querySelectorAll('.field-item'); - fieldItems.forEach(item => { - const fieldname = item.dataset.fieldname; - const label = item.querySelector('.field-label').textContent; - - if (fieldname.toLowerCase().includes(query) || label.toLowerCase().includes(query)) { - removeClass(item, 'hidden'); - } else { - addClass(item, 'hidden'); - } - }); - - // Hide empty groups - const fieldGroups = this.fieldsListElement.querySelectorAll('.field-group'); - fieldGroups.forEach(group => { - const visibleItems = group.querySelectorAll('.field-item:not(.hidden)'); - if (visibleItems.length === 0) { - addClass(group, 'hidden'); - } else { - removeClass(group, 'hidden'); - - // Expand groups with matching items - removeClass(group, 'collapsed'); - const icon = group.querySelector('.group-header i'); - if (icon) { - icon.className = 'fa fa-chevron-down'; - } - } - }); - } - - /** - * Search functions - * @param {string} query - Search query - * @private - */ - searchFunctions(query) { - if (!query) { - // Reset and show first category - const firstCategory = this.categoriesListElement.querySelector('.category-item'); - if (firstCategory) { - firstCategory.click(); - } - return; - } - - query = query.toLowerCase(); - - // Search in all categories - const allFunctions = []; - const categories = this.getFunctionCategories(); - - for (const category of categories) { - const functions = this.getFunctionsForCategory(category.id); - for (const func of functions) { - if (func.name.toLowerCase().includes(query) || - func.description.toLowerCase().includes(query)) { - // Add category info to the function - func.category = category.name; - allFunctions.push(func); - } - } - } - - // Clear functions list - this.functionsListElement.innerHTML = ''; - - // Show matching functions - if (allFunctions.length === 0) { - const noFunctions = document.createElement('div'); - noFunctions.className = 'empty-state'; - noFunctions.textContent = __('No matching functions found'); - this.functionsListElement.appendChild(noFunctions); - } else { - for (const func of allFunctions) { - const funcElement = this.createFunctionElement(func); - - // Add category badge - if (func.category) { - const badge = document.createElement('span'); - badge.className = 'category-badge'; - badge.textContent = func.category; - funcElement.querySelector('.function-header').appendChild(badge); - } - - this.functionsListElement.appendChild(funcElement); - } - } - - // Deselect all categories - const items = this.categoriesListElement.querySelectorAll('.category-item'); - items.forEach(item => removeClass(item, 'selected')); - } - - /** - * Destroy the UI and clean up - * @public - */ - destroy() { - // Remove window event listeners - window.removeEventListener('resize', this.debouncedResize); - - // Destroy Ace editor if exists - if (this.aceEditor) { - this.aceEditor.destroy(); - this.aceEditor = null; - } - - // Clear container - if (this.container) { - this.container.innerHTML = ''; - } - } -} - -export default FormulaEditorUI; \ No newline at end of file diff --git a/formula_editor/public/js/universal_fields.js b/formula_editor/public/js/universal_fields.js deleted file mode 100644 index dc09d48..0000000 --- a/formula_editor/public/js/universal_fields.js +++ /dev/null @@ -1,716 +0,0 @@ -/** - * Formula Editor Universal Fields - * Handles universal field functionality for cross-doctype formulas - */ - -import { - generateUniqueId, - isEmpty, - deepClone, - formatErrorMessage, - showToast, - debounce -} from './utils'; - -class FormulaEditorUniversalFields { - /** - * Create a new FormulaEditorUniversalFields instance - * @param {Object} options - Configuration options - * @param {Object} options.editor - Reference to the main FormulaEditor instance - * @param {Array} [options.linkedDoctypes=[]] - Initially linked doctypes - * @param {Function} [options.onChange] - Callback for field changes - */ - constructor(options) { - this.options = Object.assign({ - editor: null, - linkedDoctypes: [], - onChange: null - }, options); - - // Validate required options - if (!this.options.editor) { - throw new Error(__('Editor instance is required for FormulaEditorUniversalFields')); - } - - // Initialize properties - this.editor = this.options.editor; - this.linkedDoctypes = deepClone(this.options.linkedDoctypes || []); - this.universalFields = []; - this.fieldCache = {}; - this.debouncedOnChange = debounce(this.handleChange.bind(this), 300); - - // Initialize - this.init(); - } - - /** - * Initialize universal fields - * @private - */ - async init() { - // Ensure linked doctypes have proper structure - this.validateLinkedDoctypes(); - - // Load fields for linked doctypes - await this.loadUniversalFields(); - } - - /** - * Validate linked doctypes structure - * @private - */ - validateLinkedDoctypes() { - if (!Array.isArray(this.linkedDoctypes)) { - this.linkedDoctypes = []; - return; - } - - // Process each linked doctype to ensure it has all required properties - this.linkedDoctypes = this.linkedDoctypes.map(link => { - // Skip if not an object - if (typeof link !== 'object' || link === null) { - return null; - } - - // Ensure required properties - return { - id: link.id || generateUniqueId('link'), - label: link.label || link.doctype, - doctype: link.doctype, - linkField: link.linkField || null, - parentField: link.parentField || null, - fieldPrefix: link.fieldPrefix || link.doctype.toLowerCase().replace(/ /g, '_'), - fields: Array.isArray(link.fields) ? link.fields : [] - }; - }).filter(Boolean); // Remove invalid links - } - - /** - * Load fields for all linked doctypes - * @returns {Promise} - * @private - */ - async loadUniversalFields() { - try { - this.universalFields = []; - - // First load the main doctype fields - const mainDoctype = this.editor.options.doctype; - const mainFields = await this.getDocTypeFields(mainDoctype); - - // Add main doctype fields with doctype prefix (for clarity in formulas) - mainFields.forEach(field => { - this.universalFields.push({ - fieldname: `${mainDoctype.toLowerCase().replace(/ /g, '_')}.${field.fieldname}`, - original_fieldname: field.fieldname, - label: `${mainDoctype}: ${field.label || field.fieldname}`, - doctype: mainDoctype, - type: field.type, - options: field.options, - is_main_doctype: true - }); - }); - - // Load fields for each linked doctype - for (const link of this.linkedDoctypes) { - if (!link.doctype) continue; - - let fields = []; - - // If specific fields are defined, use those - if (Array.isArray(link.fields) && link.fields.length > 0) { - fields = link.fields.map(f => { - if (typeof f === 'string') { - return { fieldname: f, label: f }; - } - return f; - }); - } else { - // Otherwise get all fields from the doctype - fields = await this.getDocTypeFields(link.doctype); - } - - // Add to universal fields with prefix - fields.forEach(field => { - this.universalFields.push({ - fieldname: `${link.fieldPrefix}.${field.fieldname}`, - original_fieldname: field.fieldname, - label: `${link.label || link.doctype}: ${field.label || field.fieldname}`, - doctype: link.doctype, - type: field.type, - options: field.options, - linkInfo: { - linkField: link.linkField, - parentField: link.parentField, - fieldPrefix: link.fieldPrefix - }, - is_main_doctype: false - }); - }); - } - - // Notify about change - this.debouncedOnChange(); - - } catch (error) { - console.error('Error loading universal fields:', error); - showToast(__('Error loading universal fields'), 'error'); - } - } - - /** - * Get fields for a doctype - * @param {string} doctype - DocType name - * @returns {Promise} List of fields - * @private - */ - async getDocTypeFields(doctype) { - try { - // Check cache first - if (this.fieldCache[doctype]) { - return deepClone(this.fieldCache[doctype]); - } - - const fields = []; - let meta; - - // Try to get meta from cache - meta = frappe.get_meta(doctype); - - if (!meta) { - // If not in cache, fetch it - meta = await frappe.model.with_doctype(doctype); - } - - if (!meta || !meta.fields) { - throw new Error(__('Could not fetch metadata for {0}', [doctype])); - } - - // Add fields from the DocType - for (const field of meta.fields) { - // Skip certain field types - if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) { - continue; - } - - fields.push({ - fieldname: field.fieldname, - label: field.label || field.fieldname, - type: field.fieldtype, - options: field.options, - is_child_table: field.fieldtype === 'Table' - }); - } - - // Cache the fields - this.fieldCache[doctype] = deepClone(fields); - - return fields; - - } catch (error) { - console.error(`Error getting fields for doctype ${doctype}:`, error); - return []; - } - } - - /** - * Get all universal fields - * @returns {Array} List of universal fields - * @public - */ - getUniversalFields() { - return deepClone(this.universalFields); - } - - /** - * Get linked doctypes - * @returns {Array} List of linked doctypes - * @public - */ - getLinkedDoctypes() { - return deepClone(this.linkedDoctypes); - } - - /** - * Add a linked doctype - * @param {Object} linkData - Linked doctype data - * @returns {string} New link ID - * @public - */ - async addLinkedDoctype(linkData = {}) { - if (!linkData.doctype) { - throw new Error(__('DocType is required for linked doctype')); - } - - const newLink = { - id: generateUniqueId('link'), - label: linkData.label || linkData.doctype, - doctype: linkData.doctype, - linkField: linkData.linkField || null, - parentField: linkData.parentField || null, - fieldPrefix: linkData.fieldPrefix || linkData.doctype.toLowerCase().replace(/ /g, '_'), - fields: Array.isArray(linkData.fields) ? deepClone(linkData.fields) : [] - }; - - this.linkedDoctypes.push(newLink); - - // Reload universal fields - await this.loadUniversalFields(); - - return newLink.id; - } - - /** - * Update a linked doctype - * @param {string} id - Link ID - * @param {Object} updates - Properties to update - * @returns {boolean} Success status - * @public - */ - async updateLinkedDoctype(id, updates) { - const index = this.linkedDoctypes.findIndex(l => l.id === id); - if (index === -1) return false; - - // Clone the link - const link = deepClone(this.linkedDoctypes[index]); - - // Apply updates - Object.keys(updates).forEach(key => { - // Handle special properties - if (key === 'fields' && Array.isArray(updates.fields)) { - link.fields = deepClone(updates.fields); - } else if (key !== 'id') { // Don't allow changing the ID - link[key] = updates[key]; - } - }); - - // Update the link - this.linkedDoctypes[index] = link; - - // Reload universal fields - await this.loadUniversalFields(); - - return true; - } - - /** - * Delete a linked doctype - * @param {string} id - Link ID - * @returns {boolean} Success status - * @public - */ - async deleteLinkedDoctype(id) { - const index = this.linkedDoctypes.findIndex(l => l.id === id); - if (index === -1) return false; - - this.linkedDoctypes.splice(index, 1); - - // Reload universal fields - await this.loadUniversalFields(); - - return true; - } - - /** - * Search for doctypes to link - * @param {string} query - Search query - * @param {number} [limit=20] - Maximum number of results - * @returns {Promise} Search results - * @public - */ - async searchDoctypes(query, limit = 20) { - try { - const doctypes = await frappe.db.get_list('DocType', { - filters: { - name: ['like', `%${query}%`], - istable: 0, - issingle: 0 - }, - fields: ['name', 'module'], - limit: limit - }); - - return doctypes.map(dt => ({ - value: dt.name, - label: dt.name, - description: dt.module - })); - - } catch (error) { - console.error('Error searching doctypes:', error); - return []; - } - } - - /** - * Get link fields for a doctype - * @param {string} doctype - DocType name - * @returns {Promise} List of link fields - * @public - */ - async getLinkFields(doctype) { - try { - const fields = await this.getDocTypeFields(doctype); - - // Filter to only link fields - return fields.filter(f => - f.type === 'Link' || - f.type === 'Dynamic Link' - ).map(f => ({ - value: f.fieldname, - label: f.label || f.fieldname, - fieldtype: f.type, - options: f.options - })); - - } catch (error) { - console.error(`Error getting link fields for ${doctype}:`, error); - return []; - } - } - - /** - * Find potential link fields between two doctypes - * @param {string} sourceDoctype - Source DocType name - * @param {string} targetDoctype - Target DocType name - * @returns {Promise} List of potential link fields - * @public - */ - async findPotentialLinks(sourceDoctype, targetDoctype) { - try { - const sourceFields = await this.getDocTypeFields(sourceDoctype); - const potentialLinks = []; - - // Find Link fields that point to the target doctype - sourceFields.forEach(field => { - if (field.type === 'Link' && field.options === targetDoctype) { - potentialLinks.push({ - fieldname: field.fieldname, - label: field.label || field.fieldname, - linkType: 'direct', - description: __('Direct link to {0}', [targetDoctype]) - }); - } - }); - - // Look for child tables that might link to the target - sourceFields.forEach(field => { - if (field.is_child_table) { - potentialLinks.push({ - fieldname: field.fieldname, - label: field.label || field.fieldname, - linkType: 'child_table', - description: __('Child table that might contain links to {0}', [targetDoctype]), - childDoctype: field.options - }); - } - }); - - // Also check target doctype for links back to source - const targetFields = await this.getDocTypeFields(targetDoctype); - - targetFields.forEach(field => { - if (field.type === 'Link' && field.options === sourceDoctype) { - potentialLinks.push({ - fieldname: field.fieldname, - label: field.label || field.fieldname, - linkType: 'reverse', - description: __('Reverse link from {0}', [targetDoctype]) - }); - } - }); - - return potentialLinks; - - } catch (error) { - console.error(`Error finding potential links between ${sourceDoctype} and ${targetDoctype}:`, error); - return []; - } - } - - /** - * Generate a formula to fetch data from a linked doctype - * @param {string} linkId - Link ID - * @param {string} fieldname - Field name to fetch - * @returns {string} Generated formula - * @public - */ - generateLinkFormula(linkId, fieldname) { - const link = this.linkedDoctypes.find(l => l.id === linkId); - if (!link) return ''; - - const targetField = this.universalFields.find(f => - f.doctype === link.doctype && - f.original_fieldname === fieldname - ); - - if (!targetField) return ''; - - // If it's a direct link (parent to child) - if (link.linkField) { - return ` -// Get linked ${link.doctype} based on ${link.linkField} -(function() { - const linked = frappe.db.get_value('${link.doctype}', { - name: doc.${link.linkField} - }, '${fieldname}'); - - return linked ? linked.message.${fieldname} : null; -})()`; - } - - // If it's a child table link - if (link.parentField) { - return ` -// Get linked ${link.doctype} records where parent field matches -(function() { - const linked = frappe.db.get_list('${link.doctype}', { - filters: { - ${link.parentField}: doc.name - }, - fields: ['${fieldname}'] - }); - - return linked ? linked.map(item => item.${fieldname}) : []; -})()`; - } - - // Generic case - return ` -// Fetch data from ${link.doctype} -frappe.db.get_value('${link.doctype}', null, '${fieldname}')`; - } - - /** - * Generate a helper function to work with linked doctypes - * @param {string} functionType - Type of function to generate (get_linked, set_linked, etc.) - * @returns {string} Generated function code - * @public - */ - generateHelperFunction(functionType) { - switch (functionType) { - case 'get_linked': - return ` -/** - * Get a value from a linked document - * @param {string} doctype - DocType of linked document - * @param {Object} filters - Filters to find the document - * @param {string} field - Field to retrieve - * @returns {*} Field value - */ -function get_linked(doctype, filters, field) { - try { - const result = frappe.db.get_value(doctype, filters, field); - return result && result.message ? result.message[field] : null; - } catch (error) { - console.error(\`Error getting linked value: \${error}\`); - return null; - } -}`; - - case 'get_linked_list': - return ` -/** - * Get values from linked documents - * @param {string} doctype - DocType of linked documents - * @param {Object} filters - Filters to find the documents - * @param {string|Array} fields - Fields to retrieve - * @returns {Array} List of documents/values - */ -function get_linked_list(doctype, filters, fields) { - try { - return frappe.db.get_list(doctype, { - filters: filters, - fields: fields - }); - } catch (error) { - console.error(\`Error getting linked list: \${error}\`); - return []; - } -}`; - - case 'set_linked': - return ` -/** - * Update a value in a linked document - * @param {string} doctype - DocType of linked document - * @param {string} name - Name of linked document - * @param {Object} values - Values to update - * @returns {boolean} Success status - */ -function set_linked(doctype, name, values) { - try { - frappe.db.set_value(doctype, name, values); - return true; - } catch (error) { - console.error(\`Error setting linked value: \${error}\`); - return false; - } -}`; - - default: - return ''; - } - } - - /** - * Get a preview of data from linked doctypes - * @param {Object} doc - Current document - * @returns {Promise} Preview data - * @public - */ - async getLinkedDataPreview(doc) { - if (!doc) return {}; - - const preview = {}; - - for (const link of this.linkedDoctypes) { - try { - preview[link.fieldPrefix] = await this.getLinkedDocTypeData(link, doc); - } catch (error) { - console.error(`Error getting preview for ${link.doctype}:`, error); - preview[link.fieldPrefix] = { error: formatErrorMessage(error) }; - } - } - - return preview; - } - - /** - * Get data for a linked doctype - * @param {Object} link - Link configuration - * @param {Object} doc - Current document - * @returns {Promise} Linked data - * @private - */ - async getLinkedDocTypeData(link, doc) { - // If it's a direct link (parent to child) - if (link.linkField && doc[link.linkField]) { - // Get single linked document - const result = await frappe.db.get_value( - link.doctype, - { name: doc[link.linkField] }, - '*' - ); - - return result && result.message ? result.message : null; - } - - // If it's a child table link (child to parent) - if (link.parentField) { - // Get multiple linked documents - const result = await frappe.db.get_list( - link.doctype, - { - filters: { - [link.parentField]: doc.name - }, - fields: ['*'], - limit: 10 - } - ); - - return result || []; - } - - // If no specific link field is defined, return empty - return null; - } - - /** - * Handle field changes - * @private - */ - handleChange() { - if (typeof this.options.onChange === 'function') { - this.options.onChange(this.universalFields); - } - - // Update editor fields to include universal fields - if (this.editor && typeof this.editor.setFields === 'function') { - // Convert universal fields to editor field format - const editorFields = this.universalFields.map(field => ({ - fieldname: field.fieldname, - label: field.label, - type: field.type, - options: field.options, - universal: true, - doctype: field.doctype - })); - - this.editor.setFields(editorFields); - } - } - - /** - * Import linked doctypes configuration from JSON - * @param {string|Object} json - JSON string or object - * @returns {Promise} Success status - * @public - */ - async importLinkedDoctypes(json) { - try { - let data; - - if (typeof json === 'string') { - data = JSON.parse(json); - } else if (typeof json === 'object' && json !== null) { - data = json; - } else { - throw new Error(__('Invalid import data format')); - } - - if (!Array.isArray(data)) { - throw new Error(__('Import data must be an array')); - } - - // Validate each linked doctype - const validLinks = data.filter(link => - typeof link === 'object' && - link !== null && - typeof link.doctype === 'string' - ); - - if (validLinks.length === 0) { - throw new Error(__('No valid linked doctypes found in import data')); - } - - // Replace linked doctypes - this.linkedDoctypes = validLinks.map(link => ({ - id: link.id || generateUniqueId('link'), - label: link.label || link.doctype, - doctype: link.doctype, - linkField: link.linkField || null, - parentField: link.parentField || null, - fieldPrefix: link.fieldPrefix || link.doctype.toLowerCase().replace(/ /g, '_'), - fields: Array.isArray(link.fields) ? link.fields : [] - })); - - // Reload universal fields - await this.loadUniversalFields(); - - return true; - - } catch (error) { - console.error('Error importing linked doctypes:', error); - return false; - } - } - - /** - * Export linked doctypes configuration to JSON - * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON - * @returns {string} JSON string - * @public - */ - exportLinkedDoctypes(prettyPrint = false) { - try { - return JSON.stringify(this.linkedDoctypes, null, prettyPrint ? 2 : 0); - } catch (error) { - console.error('Error exporting linked doctypes:', error); - return '[]'; - } - } -} - -export default FormulaEditorUniversalFields; \ No newline at end of file diff --git a/formula_editor/public/js/utils.js b/formula_editor/public/js/utils.js deleted file mode 100644 index a461b09..0000000 --- a/formula_editor/public/js/utils.js +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Formula Editor Utilities - * Contains helper functions used across the Formula Editor modules - */ - -import { __ } from 'frappe.utils'; - -/** - * Safely evaluates a formula/expression - * @param {string} formula - The formula to evaluate - * @param {Object} context - Context variables for the formula - * @returns {*} The result of the evaluation - */ -function safeEval(formula, context = {}) { - try { - // Create a safe function with the provided context - const contextKeys = Object.keys(context); - const contextValues = Object.values(context); - - // Using Function constructor with context variables as parameters - const evalFunction = new Function(...contextKeys, `"use strict"; return (${formula});`); - - return evalFunction(...contextValues); - } catch (error) { - console.error(`Error evaluating formula: ${formula}`, error); - return null; - } -} - -/** - * Validates if a formula is syntactically correct - * @param {string} formula - The formula to validate - * @returns {Object} Result with isValid and error message if any - */ -function validateFormula(formula) { - try { - // Simple validation by trying to parse as function - new Function(`"use strict"; return (${formula});`); - return { isValid: true, error: null }; - } catch (error) { - return { - isValid: false, - error: error.message.replace(/Function|new|constructor|[()]/g, '') - }; - } -} - -/** - * Escapes special characters in a string for use in regex - * @param {string} str - String to escape - * @returns {string} Escaped string - */ -function escapeRegExp(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Gets all variables/fields referenced in a formula - * @param {string} formula - The formula to analyze - * @param {Array} availableFields - List of available fields - * @returns {Array} Array of field names found in the formula - */ -function extractReferencedFields(formula, availableFields) { - if (!formula || !availableFields || !availableFields.length) { - return []; - } - - const referencedFields = []; - - // Sort fields by length (descending) to match longest field names first - const sortedFields = [...availableFields].sort((a, b) => b.length - a.length); - - for (const field of sortedFields) { - // Create a regex to find the field name (with word boundaries) - const fieldRegex = new RegExp(`\\b${escapeRegExp(field)}\\b`, 'g'); - if (fieldRegex.test(formula)) { - referencedFields.push(field); - } - } - - return referencedFields; -} - -/** - * Creates a unique ID for DOM elements - * @param {string} prefix - Prefix for the ID - * @returns {string} Unique ID - */ -function generateUniqueId(prefix = 'fe') { - return `${prefix}-${Math.random().toString(36).substr(2, 9)}`; -} - -/** - * Debounces a function call - * @param {Function} func - Function to debounce - * @param {number} wait - Delay in milliseconds - * @returns {Function} Debounced function - */ -function debounce(func, wait = 300) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - -/** - * Throttles a function call - * @param {Function} func - Function to throttle - * @param {number} limit - Limit in milliseconds - * @returns {Function} Throttled function - */ -function throttle(func, limit = 300) { - let inThrottle; - return function executedFunction(...args) { - if (!inThrottle) { - func(...args); - inThrottle = true; - setTimeout(() => { - inThrottle = false; - }, limit); - } - }; -} - -/** - * Formats error messages for display - * @param {Error|string} error - Error object or message - * @returns {string} Formatted error message - */ -function formatErrorMessage(error) { - if (!error) return ''; - - const errorMsg = typeof error === 'string' ? error : error.message || __('Unknown Error'); - return errorMsg.replace(/^Error:\s*/i, ''); -} - -/** - * Shows a toast notification - * @param {string} message - Message to display - * @param {string} type - Type of notification (success, error, warning) - */ -function showToast(message, type = 'error') { - frappe.show_alert({ - message: message, - indicator: type - }, 5); -} - -/** - * Converts a field name to a human-readable label - * @param {string} fieldname - Field name to convert - * @returns {string} Human readable field label - */ -function fieldToLabel(fieldname) { - if (!fieldname) return ''; - - // Replace underscores and dots with spaces - let label = fieldname.replace(/[_\.]/g, ' '); - - // Capitalize each word - label = label.replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()); - - return label; -} - -/** - * Adds a CSS class to an element if not already applied - * @param {HTMLElement} element - DOM element - * @param {string} className - CSS class to add - */ -function addClass(element, className) { - if (!element) return; - - if (!element.classList.contains(className)) { - element.classList.add(className); - } -} - -/** - * Removes a CSS class from an element - * @param {HTMLElement} element - DOM element - * @param {string} className - CSS class to remove - */ -function removeClass(element, className) { - if (!element) return; - - if (element.classList.contains(className)) { - element.classList.remove(className); - } -} - -/** - * Toggles a CSS class on an element - * @param {HTMLElement} element - DOM element - * @param {string} className - CSS class to toggle - * @param {boolean} condition - Optional condition to determine toggle - */ -function toggleClass(element, className, condition) { - if (!element) return; - - if (condition === undefined) { - element.classList.toggle(className); - } else { - condition ? addClass(element, className) : removeClass(element, className); - } -} - -/** - * Parses a string into a JSON object safely - * @param {string} str - String to parse - * @param {*} defaultValue - Default value if parsing fails - * @returns {*} Parsed JSON or default value - */ -function safeParseJSON(str, defaultValue = {}) { - try { - return JSON.parse(str); - } catch (e) { - return defaultValue; - } -} - -/** - * Checks if a variable is empty (null, undefined, empty string, empty array, empty object) - * @param {*} value - Value to check - * @returns {boolean} True if empty - */ -function isEmpty(value) { - if (value === null || value === undefined) return true; - if (typeof value === 'string' && value.trim() === '') return true; - if (Array.isArray(value) && value.length === 0) return true; - if (typeof value === 'object' && Object.keys(value).length === 0) return true; - - return false; -} - -/** - * Deep clones an object - * @param {*} obj - Object to clone - * @returns {*} Cloned object - */ -function deepClone(obj) { - if (obj === null || typeof obj !== 'object') return obj; - - try { - return JSON.parse(JSON.stringify(obj)); - } catch (e) { - // Fallback for circular references - const clone = Array.isArray(obj) ? [] : {}; - - Object.keys(obj).forEach(key => { - clone[key] = typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key]; - }); - - return clone; - } -} - -/** - * Gets a value from an object using a path string (e.g., "customer.address.city") - * @param {Object} obj - Object to get value from - * @param {string} path - Path to the value - * @param {*} defaultValue - Default value if path doesn't exist - * @returns {*} Value at path or default value - */ -function getValueByPath(obj, path, defaultValue = undefined) { - if (!obj || !path) return defaultValue; - - const keys = path.split('.'); - let current = obj; - - for (const key of keys) { - if (current === undefined || current === null) { - return defaultValue; - } - - current = current[key]; - } - - return current !== undefined ? current : defaultValue; -} - -/** - * Checks if a key or formula is a valid JavaScript identifier - * @param {string} key - Key to validate - * @returns {boolean} True if valid - */ -function isValidIdentifier(key) { - if (!key || typeof key !== 'string') return false; - - // JavaScript identifier regex (letters, numbers, underscore, dollar sign) - // Must start with letter, underscore or dollar sign - const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; - - return validIdentifierRegex.test(key); -} - -export { - safeEval, - validateFormula, - escapeRegExp, - extractReferencedFields, - generateUniqueId, - debounce, - throttle, - formatErrorMessage, - showToast, - fieldToLabel, - addClass, - removeClass, - toggleClass, - safeParseJSON, - isEmpty, - deepClone, - getValueByPath, - isValidIdentifier -}; \ No newline at end of file