fixed bug with report field not having edit and delete button
This commit is contained in:
parent
8750e6b688
commit
33939cbfc4
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 = $('<div class="formula-editor-field-wrapper"></div>');
|
||||
field.$wrapper.find('.control-input-wrapper').append($wrapper);
|
||||
|
||||
// Create editor
|
||||
const editorOptions = {
|
||||
id: fieldId,
|
||||
parent: $wrapper[0],
|
||||
doctype: field.frm.doctype,
|
||||
fieldname: field.df.fieldname,
|
||||
doc: field.frm.doc,
|
||||
on_change: (value, referencedFields) => {
|
||||
field.set_input(value);
|
||||
|
||||
// Store referenced fields to update formula when they change
|
||||
if (Array.isArray(referencedFields)) {
|
||||
field._formulaReferencedFields = referencedFields;
|
||||
}
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
const editor = this.create(editorOptions);
|
||||
|
||||
// Set up field change listener to keep formula in sync
|
||||
field._originalRefresh = field.refresh;
|
||||
field.refresh = () => {
|
||||
const result = field._originalRefresh();
|
||||
|
||||
// Update editor value if it doesn't match field value
|
||||
if (editor.getValue() !== field.value) {
|
||||
editor.setValue(field.value);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Set up listener for referenced fields
|
||||
this._setupReferencedFieldsListeners(field.frm, field);
|
||||
|
||||
return editor;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up listeners for fields referenced in a formula
|
||||
* @param {frappe.ui.form.Form} frm - Form instance
|
||||
* @param {frappe.ui.form.Field} formulaField - Formula field
|
||||
* @private
|
||||
*/
|
||||
_setupReferencedFieldsListeners(frm, formulaField) {
|
||||
if (!frm || !formulaField || !formulaField._formulaReferencedFields) return;
|
||||
|
||||
const referencedFields = formulaField._formulaReferencedFields;
|
||||
|
||||
referencedFields.forEach(fieldname => {
|
||||
const field = frm.fields_dict[fieldname];
|
||||
if (!field) return;
|
||||
|
||||
// Skip if already set up
|
||||
if (field._formulaListenerSetup) return;
|
||||
|
||||
// Set up change listener
|
||||
const originalOnChange = field.df.change;
|
||||
field.df.change = () => {
|
||||
if (typeof originalOnChange === 'function') {
|
||||
originalOnChange();
|
||||
}
|
||||
|
||||
// Update all formula fields that reference this field
|
||||
Object.values(frm.fields_dict).forEach(f => {
|
||||
if (f._formulaReferencedFields &&
|
||||
f._formulaReferencedFields.includes(fieldname) &&
|
||||
f !== formulaField) {
|
||||
|
||||
// Get the editor
|
||||
const editorId = `${frm.doctype}_${f.df.fieldname}`;
|
||||
const editor = frappe.formula_editor.get(editorId);
|
||||
|
||||
if (editor) {
|
||||
// Re-evaluate the formula
|
||||
const result = editor.evaluate();
|
||||
if (result !== undefined) {
|
||||
f.set_input(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
field._formulaListenerSetup = true;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize formula editor for a report
|
||||
* @param {frappe.views.ReportView|frappe.query_reports.QueryReport} report - Report instance
|
||||
* @param {string} reportName - Report name
|
||||
* @param {string} [reportType='report'] - Report type
|
||||
* @returns {FormulaEditorReportFields} Report fields handler instance
|
||||
*/
|
||||
initForReport(report, reportName, reportType = 'report') {
|
||||
if (!report || !reportName) {
|
||||
console.error('Invalid report provided to initForReport');
|
||||
return null;
|
||||
}
|
||||
|
||||
const reportId = `${reportType}_${reportName}`;
|
||||
|
||||
// Check if already initialized
|
||||
if (report._formulaEditor) return report._formulaEditor;
|
||||
|
||||
// Create a minimal editor instance for the report
|
||||
const editorOptions = {
|
||||
id: reportId,
|
||||
doctype: reportName,
|
||||
is_report: true
|
||||
};
|
||||
|
||||
const editor = new FormulaEditor(editorOptions);
|
||||
|
||||
// Create report fields handler
|
||||
const reportFields = new FormulaEditorReportFields({
|
||||
editor: editor,
|
||||
reportFormulas: []
|
||||
});
|
||||
|
||||
// Integrate with the report
|
||||
reportFields.integrateWithReport(report, reportName, reportType);
|
||||
|
||||
// Store reference on the report
|
||||
report._formulaEditor = reportFields;
|
||||
|
||||
return reportFields;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize table row formulas for a form
|
||||
* @param {frappe.ui.form.Form} frm - Form instance
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @returns {FormulaEditorTableRows} Table rows handler instance
|
||||
*/
|
||||
initTableFormulas(frm, options = {}) {
|
||||
if (!frm || !frm.doctype) {
|
||||
console.error('Invalid form provided to initTableFormulas');
|
||||
return null;
|
||||
}
|
||||
|
||||
const formId = `table_formulas_${frm.doctype}`;
|
||||
|
||||
// Check if already initialized
|
||||
if (frm._tableFormulasEditor) return frm._tableFormulasEditor;
|
||||
|
||||
// Create a minimal editor instance for the form
|
||||
const editorOptions = {
|
||||
id: formId,
|
||||
doctype: frm.doctype,
|
||||
doc: frm.doc
|
||||
};
|
||||
|
||||
const editor = new FormulaEditor(editorOptions);
|
||||
|
||||
// Create table rows handler
|
||||
const tableRows = new FormulaEditorTableRows({
|
||||
editor: editor,
|
||||
tableFormulas: options.tableFormulas || [],
|
||||
onChange: options.onChange
|
||||
});
|
||||
|
||||
// Attach to the form
|
||||
tableRows.attachToForm(frm);
|
||||
|
||||
// Store reference on the form
|
||||
frm._tableFormulasEditor = tableRows;
|
||||
|
||||
return tableRows;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize triggers for a form
|
||||
* @param {frappe.ui.form.Form} frm - Form instance
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @returns {FormulaEditorTriggers} Triggers handler instance
|
||||
*/
|
||||
initTriggers(frm, options = {}) {
|
||||
if (!frm || !frm.doctype) {
|
||||
console.error('Invalid form provided to initTriggers');
|
||||
return null;
|
||||
}
|
||||
|
||||
const formId = `triggers_${frm.doctype}`;
|
||||
|
||||
// Check if already initialized
|
||||
if (frm._triggersEditor) return frm._triggersEditor;
|
||||
|
||||
// Create a minimal editor instance for the form
|
||||
const editorOptions = {
|
||||
id: formId,
|
||||
doctype: frm.doctype,
|
||||
doc: frm.doc,
|
||||
is_trigger: true
|
||||
};
|
||||
|
||||
const editor = new FormulaEditor(editorOptions);
|
||||
|
||||
// Create triggers handler
|
||||
const triggers = new FormulaEditorTriggers({
|
||||
editor: editor,
|
||||
triggers: options.triggers || [],
|
||||
onChange: options.onChange
|
||||
});
|
||||
|
||||
// Attach to the form
|
||||
triggers.attachToForm(frm);
|
||||
|
||||
// Store reference on the form
|
||||
frm._triggersEditor = triggers;
|
||||
|
||||
return triggers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a field into a formula editor
|
||||
* @param {frappe.ui.form.Form} frm - Form instance
|
||||
* @param {string} fieldname - Field to convert
|
||||
* @param {Object} [options={}] - Additional options
|
||||
*/
|
||||
makeFieldFormula(frm, fieldname, options = {}) {
|
||||
if (!frm || !fieldname) return;
|
||||
|
||||
const field = frm.get_field(fieldname);
|
||||
if (!field) return;
|
||||
|
||||
// Add formula button to the field
|
||||
if (!field.$wrapper.find('.formula-editor-btn').length) {
|
||||
const $btn = $(`
|
||||
<button class="btn btn-xs btn-default formula-editor-btn" title="${__('Edit Formula')}">
|
||||
<i class="fa fa-calculator"></i>
|
||||
</button>
|
||||
`);
|
||||
|
||||
field.$wrapper.find('.control-input-wrapper').append($btn);
|
||||
|
||||
// Show formula editor on click
|
||||
$btn.on('click', () => {
|
||||
this.showFieldFormulaDialog(frm, fieldname, options);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show formula editor dialog for a field
|
||||
* @param {frappe.ui.form.Form} frm - Form instance
|
||||
* @param {string} fieldname - Field name
|
||||
* @param {Object} [options={}] - Additional options
|
||||
*/
|
||||
showFieldFormulaDialog(frm, fieldname, options = {}) {
|
||||
const field = frm.get_field(fieldname);
|
||||
if (!field) return;
|
||||
|
||||
// Create dialog
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __('Formula Editor: {0}', [field.df.label || fieldname]),
|
||||
fields: [
|
||||
{
|
||||
fieldtype: 'Code',
|
||||
fieldname: 'formula',
|
||||
label: __('Formula'),
|
||||
options: 'JavaScript',
|
||||
default: field.value || ''
|
||||
}
|
||||
],
|
||||
primary_action_label: __('Apply'),
|
||||
primary_action: (values) => {
|
||||
field.set_input(values.formula);
|
||||
dialog.hide();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
};
|
||||
|
||||
// Register setup hooks
|
||||
$(document).on('form-load', function(event, frm) {
|
||||
// Find formula fields and convert them to formula editors
|
||||
frm.meta.fields.forEach(df => {
|
||||
if (df.fieldtype === 'Code' && df.options === 'Formula') {
|
||||
frappe.formula_editor.makeFieldFormula(frm, df.fieldname);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default frappe.formula_editor;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<Array>} List of field options
|
||||
* @public
|
||||
*/
|
||||
async getChildTableFields(childDoctype) {
|
||||
try {
|
||||
let meta;
|
||||
|
||||
// Try to get meta from cache
|
||||
meta = frappe.get_meta(childDoctype);
|
||||
|
||||
if (!meta) {
|
||||
// If not in cache, fetch it
|
||||
meta = await frappe.model.with_doctype(childDoctype);
|
||||
}
|
||||
|
||||
if (!meta || !meta.fields) {
|
||||
throw new Error(__('Could not fetch metadata for {0}', [childDoctype]));
|
||||
}
|
||||
|
||||
const fields = [];
|
||||
|
||||
// Add fields from the child DocType
|
||||
for (const field of meta.fields) {
|
||||
// Skip certain field types
|
||||
if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.push({
|
||||
fieldname: field.fieldname,
|
||||
label: field.label || field.fieldname,
|
||||
type: field.fieldtype,
|
||||
options: field.options,
|
||||
is_child_table: field.fieldtype === 'Table'
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error getting fields for child doctype ${childDoctype}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply formulas to a table
|
||||
* @param {Object} doc - Parent document
|
||||
* @param {string} tableField - Table field name
|
||||
* @param {Object} [options] - Options for applying formulas
|
||||
* @param {boolean} [options.silent=false] - Whether to suppress notifications
|
||||
* @returns {Promise<Object>} Result with success status and error if any
|
||||
* @public
|
||||
*/
|
||||
async applyTableFormulas(doc, tableField, options = {}) {
|
||||
const silent = options.silent || false;
|
||||
|
||||
if (!doc || !tableField || !doc[tableField] || !Array.isArray(doc[tableField])) {
|
||||
return { success: false, error: __('Invalid table data') };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get formulas for this table
|
||||
const formulas = this.getFormulasForTable(tableField);
|
||||
|
||||
if (formulas.length === 0) {
|
||||
return { success: true, message: __('No formulas to apply') };
|
||||
}
|
||||
|
||||
// Apply each formula to all rows
|
||||
for (const formula of formulas) {
|
||||
if (!formula.enabled) continue;
|
||||
|
||||
for (let rowIdx = 0; rowIdx < doc[tableField].length; rowIdx++) {
|
||||
await this.applyFormulaToRow(formula, doc, rowIdx);
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
showToast(__('Applied {0} formulas to {1} rows', [formulas.length, doc[tableField].length]), 'success');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying table formulas:', error);
|
||||
if (!silent) {
|
||||
showToast(__('Error applying table formulas: {0}', [formatErrorMessage(error)]), 'error');
|
||||
}
|
||||
return { success: false, error: formatErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a formula to a specific row
|
||||
* @param {Object} formula - Formula configuration
|
||||
* @param {Object} doc - Parent document
|
||||
* @param {number} rowIdx - Row index
|
||||
* @returns {Promise<boolean>} Success status
|
||||
* @private
|
||||
*/
|
||||
async applyFormulaToRow(formula, doc, rowIdx) {
|
||||
try {
|
||||
if (!formula.enabled || !formula.formula.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const table = doc[formula.table_field];
|
||||
if (!table || !Array.isArray(table) || rowIdx >= table.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const row = table[rowIdx];
|
||||
const previousRows = table.slice(0, rowIdx);
|
||||
const allRows = table;
|
||||
|
||||
// Create evaluation context
|
||||
const context = {
|
||||
doc: doc,
|
||||
row: row,
|
||||
rowIdx: rowIdx,
|
||||
previousRows: previousRows,
|
||||
allRows: allRows,
|
||||
frappe: frappe
|
||||
};
|
||||
|
||||
// Evaluate the formula
|
||||
const result = await this.evaluateRowFormula(formula.formula, context);
|
||||
|
||||
// Apply the result to the target field
|
||||
if (result !== undefined) {
|
||||
row[formula.target_field] = result;
|
||||
|
||||
// If this is a cumulative formula, propagate changes to subsequent rows
|
||||
if (formula.is_cumulative) {
|
||||
await this.propagateCumulativeChanges(formula, doc, rowIdx);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error applying formula to row ${rowIdx}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a formula in the context of a row
|
||||
* @param {string} formula - Formula to evaluate
|
||||
* @param {Object} context - Evaluation context
|
||||
* @returns {Promise<*>} Evaluation result
|
||||
* @private
|
||||
*/
|
||||
async evaluateRowFormula(formula, context) {
|
||||
return safeEval(formula, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate cumulative changes to subsequent rows
|
||||
* @param {Object} formula - Formula configuration
|
||||
* @param {Object} doc - Parent document
|
||||
* @param {number} startRowIdx - Starting row index
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async propagateCumulativeChanges(formula, doc, startRowIdx) {
|
||||
const table = doc[formula.table_field];
|
||||
if (!table || !Array.isArray(table) || startRowIdx >= table.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply formula to all subsequent rows
|
||||
for (let rowIdx = startRowIdx + 1; rowIdx < table.length; rowIdx++) {
|
||||
await this.applyFormulaToRow(formula, doc, rowIdx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for a form
|
||||
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||
* @public
|
||||
*/
|
||||
attachToForm(form) {
|
||||
if (!form) return;
|
||||
|
||||
// Store the current form
|
||||
this.currentForm = form;
|
||||
|
||||
// Set up table field change listeners
|
||||
this.childTableFields.forEach(tableField => {
|
||||
const fieldName = tableField.fieldname;
|
||||
|
||||
// Get the grid for this table
|
||||
const grid = form.fields_dict[fieldName]?.grid;
|
||||
if (!grid) return;
|
||||
|
||||
// Save original grid row functions to extend them
|
||||
if (!grid._original_refresh) {
|
||||
grid._original_refresh = grid.refresh;
|
||||
grid.refresh = () => {
|
||||
grid._original_refresh();
|
||||
this.onGridRefresh(grid, fieldName, form);
|
||||
};
|
||||
}
|
||||
|
||||
if (!grid._original_add_new_row) {
|
||||
grid._original_add_new_row = grid.add_new_row;
|
||||
grid.add_new_row = (idx, callback, from_bottom) => {
|
||||
grid._original_add_new_row(idx, (row) => {
|
||||
if (callback) callback(row);
|
||||
this.onRowAdded(grid, row, fieldName, form);
|
||||
}, from_bottom);
|
||||
};
|
||||
}
|
||||
|
||||
if (!grid._original_setup_columns) {
|
||||
grid._original_setup_columns = grid.setup_columns;
|
||||
grid.setup_columns = () => {
|
||||
grid._original_setup_columns();
|
||||
this.setupGridColumns(grid, fieldName, form);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Set up form save listener
|
||||
if (!form._tableFormulas_original_save) {
|
||||
form._tableFormulas_original_save = form.save;
|
||||
form.save = (save_action, callback, btn, on_error) => {
|
||||
// Apply 'save' formulas
|
||||
this.applyAllFormulasOnSave(form).then(() => {
|
||||
// Call original save method
|
||||
form._tableFormulas_original_save(save_action, callback, btn, on_error);
|
||||
}).catch(error => {
|
||||
console.error('Error applying table formulas on save:', error);
|
||||
// Still call original save
|
||||
form._tableFormulas_original_save(save_action, callback, btn, on_error);
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listeners from a form
|
||||
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||
* @public
|
||||
*/
|
||||
detachFromForm(form) {
|
||||
if (!form) return;
|
||||
|
||||
// Restore original grid functions
|
||||
this.childTableFields.forEach(tableField => {
|
||||
const fieldName = tableField.fieldname;
|
||||
const grid = form.fields_dict[fieldName]?.grid;
|
||||
|
||||
if (!grid) return;
|
||||
|
||||
if (grid._original_refresh) {
|
||||
grid.refresh = grid._original_refresh;
|
||||
delete grid._original_refresh;
|
||||
}
|
||||
|
||||
if (grid._original_add_new_row) {
|
||||
grid.add_new_row = grid._original_add_new_row;
|
||||
delete grid._original_add_new_row;
|
||||
}
|
||||
|
||||
if (grid._original_setup_columns) {
|
||||
grid.setup_columns = grid._original_setup_columns;
|
||||
delete grid._original_setup_columns;
|
||||
}
|
||||
});
|
||||
|
||||
// Restore original save method
|
||||
if (form._tableFormulas_original_save) {
|
||||
form.save = form._tableFormulas_original_save;
|
||||
delete form._tableFormulas_original_save;
|
||||
}
|
||||
|
||||
// Clear current form reference
|
||||
this.currentForm = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle grid refresh event
|
||||
* @param {frappe.ui.form.Grid} grid - Grid instance
|
||||
* @param {string} fieldName - Table field name
|
||||
* @param {frappe.ui.form.Form} form - Form instance
|
||||
* @private
|
||||
*/
|
||||
onGridRefresh(grid, fieldName, form) {
|
||||
// Set up row event handlers
|
||||
grid.grid_rows.forEach(row => {
|
||||
this.setupRowEvents(row, fieldName, form);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle row added event
|
||||
* @param {frappe.ui.form.Grid} grid - Grid instance
|
||||
* @param {frappe.ui.form.GridRow} row - Grid row instance
|
||||
* @param {string} fieldName - Table field name
|
||||
* @param {frappe.ui.form.Form} form - Form instance
|
||||
* @private
|
||||
*/
|
||||
onRowAdded(grid, row, fieldName, form) {
|
||||
// Set up events for the new row
|
||||
this.setupRowEvents(row, fieldName, form);
|
||||
|
||||
// Apply 'change' formulas to the new row
|
||||
setTimeout(() => {
|
||||
const formulas = this.getFormulasForTable(fieldName).filter(f =>
|
||||
f.enabled && f.apply_on === 'change'
|
||||
);
|
||||
|
||||
if (formulas.length > 0) {
|
||||
const rowIdx = row.doc.idx - 1;
|
||||
|
||||
// Apply each formula
|
||||
formulas.forEach(formula => {
|
||||
this.applyFormulaToRow(formula, form.doc, rowIdx)
|
||||
.then(() => {
|
||||
// Refresh the row to show updated values
|
||||
row.refresh();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Error applying formula to new row:`, error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up events for a grid row
|
||||
* @param {frappe.ui.form.GridRow} row - Grid row instance
|
||||
* @param {string} fieldName - Table field name
|
||||
* @param {frappe.ui.form.Form} form - Form instance
|
||||
* @private
|
||||
*/
|
||||
setupRowEvents(row, fieldName, form) {
|
||||
// Skip if already set up
|
||||
if (row._formulaEventsSetup) return;
|
||||
|
||||
// Get formulas for this table
|
||||
const formulas = this.getFormulasForTable(fieldName).filter(f =>
|
||||
f.enabled && f.apply_on === 'change'
|
||||
);
|
||||
|
||||
if (formulas.length === 0) return;
|
||||
|
||||
// Set up field change handlers
|
||||
const sourceFields = new Set();
|
||||
formulas.forEach(formula => {
|
||||
formula.source_fields.forEach(field => {
|
||||
sourceFields.add(field);
|
||||
});
|
||||
|
||||
// Also add fields referenced in the formula
|
||||
const referencedFields = extractReferencedFields(formula.formula, []);
|
||||
referencedFields.forEach(field => {
|
||||
if (field.startsWith('row.')) {
|
||||
const rowField = field.substring(4);
|
||||
sourceFields.add(rowField);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// For each source field, set up a change handler
|
||||
sourceFields.forEach(fieldName => {
|
||||
const field = row.fields_dict[fieldName];
|
||||
if (!field) return;
|
||||
|
||||
// If the field already has our wrapper, don't add another one
|
||||
if (field._formulaChangeHandlerAttached) return;
|
||||
|
||||
// Get the control's change function
|
||||
const controlProto = Object.getPrototypeOf(field);
|
||||
if (!controlProto || !controlProto.set_input) return;
|
||||
|
||||
// Save original set_input function
|
||||
field._original_set_input = field.set_input;
|
||||
|
||||
// Override set_input to catch changes
|
||||
field.set_input = function() {
|
||||
const oldValue = field.value;
|
||||
|
||||
// Call original function
|
||||
field._original_set_input.apply(field, arguments);
|
||||
|
||||
const newValue = field.value;
|
||||
|
||||
// If value changed, trigger our handler
|
||||
if (oldValue !== newValue) {
|
||||
row._formula_on_field_change(fieldName, newValue, oldValue);
|
||||
}
|
||||
};
|
||||
|
||||
field._formulaChangeHandlerAttached = true;
|
||||
});
|
||||
|
||||
// Add custom field change handler to the row
|
||||
row._formula_on_field_change = debounce((changedField, newValue, oldValue) => {
|
||||
const rowIdx = row.doc.idx - 1;
|
||||
|
||||
// Find formulas that depend on this field
|
||||
const relevantFormulas = formulas.filter(formula =>
|
||||
formula.source_fields.includes(changedField) ||
|
||||
formula.formula.includes(`row.${changedField}`)
|
||||
);
|
||||
|
||||
if (relevantFormulas.length === 0) return;
|
||||
|
||||
// Apply each relevant formula
|
||||
relevantFormulas.forEach(formula => {
|
||||
this.applyFormulaToRow(formula, form.doc, rowIdx)
|
||||
.then(() => {
|
||||
// Refresh the row to show updated values
|
||||
row.refresh();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Error applying formula after field change:`, error);
|
||||
});
|
||||
});
|
||||
}, 300);
|
||||
|
||||
row._formulaEventsSetup = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up grid columns for better formula UX
|
||||
* @param {frappe.ui.form.Grid} grid - Grid instance
|
||||
* @param {string} fieldName - Table field name
|
||||
* @param {frappe.ui.form.Form} form - Form instance
|
||||
* @private
|
||||
*/
|
||||
setupGridColumns(grid, fieldName, form) {
|
||||
// Get formulas for this table
|
||||
const formulas = this.getFormulasForTable(fieldName);
|
||||
|
||||
// Find columns that are calculated by formulas
|
||||
formulas.forEach(formula => {
|
||||
if (!formula.enabled) return;
|
||||
|
||||
const colField = grid.grid_columns_by_fieldname[formula.target_field];
|
||||
if (!colField) return;
|
||||
|
||||
// Add a class to indicate it's calculated
|
||||
const headerElement = colField.header_field;
|
||||
if (headerElement && headerElement.classList) {
|
||||
headerElement.classList.add('formula-calculated-field');
|
||||
|
||||
// Add tooltip
|
||||
headerElement.title = __('This field is calculated by a formula');
|
||||
|
||||
// Add formula info if grid has a datatable
|
||||
if (grid.datatable) {
|
||||
const colIdx = grid.datatable.getColumnIndex(formula.target_field);
|
||||
if (colIdx !== -1) {
|
||||
const col = grid.datatable.datamanager.columns[colIdx];
|
||||
if (col) {
|
||||
col.formula = formula.formula;
|
||||
col.isFormula = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all formulas that should run on save
|
||||
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async applyAllFormulasOnSave(form) {
|
||||
if (!form) return;
|
||||
|
||||
// Process each table field
|
||||
for (const tableField of this.childTableFields) {
|
||||
const fieldName = tableField.fieldname;
|
||||
|
||||
// Get 'save' formulas for this table
|
||||
const formulas = this.getFormulasForTable(fieldName).filter(f =>
|
||||
f.enabled && f.apply_on === 'save'
|
||||
);
|
||||
|
||||
if (formulas.length > 0) {
|
||||
// Apply formulas to the table
|
||||
await this.applyTableFormulas(form.doc, fieldName, { silent: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle formula changes
|
||||
* @private
|
||||
*/
|
||||
handleChange() {
|
||||
if (typeof this.options.onChange === 'function') {
|
||||
this.options.onChange(this.tableFormulas);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import table formulas from JSON
|
||||
* @param {string|Object} json - JSON string or object
|
||||
* @returns {boolean} Success status
|
||||
* @public
|
||||
*/
|
||||
importTableFormulas(json) {
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (typeof json === 'string') {
|
||||
data = JSON.parse(json);
|
||||
} else if (typeof json === 'object' && json !== null) {
|
||||
data = json;
|
||||
} else {
|
||||
throw new Error(__('Invalid import data format'));
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(__('Import data must be an array'));
|
||||
}
|
||||
|
||||
// Validate each formula
|
||||
const validFormulas = data.filter(formula =>
|
||||
typeof formula === 'object' &&
|
||||
formula !== null &&
|
||||
typeof formula.table_field === 'string' &&
|
||||
typeof formula.target_field === 'string'
|
||||
);
|
||||
|
||||
if (validFormulas.length === 0) {
|
||||
throw new Error(__('No valid table formulas found in import data'));
|
||||
}
|
||||
|
||||
// Replace table formulas
|
||||
this.tableFormulas = validFormulas.map(formula => {
|
||||
// Find table field in our list
|
||||
const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field);
|
||||
|
||||
return {
|
||||
id: formula.id || `${formula.table_field}_${formula.target_field}_formula`,
|
||||
name: formula.name || __('Formula for {0}', [formula.target_field]),
|
||||
table_field: formula.table_field,
|
||||
target_field: formula.target_field,
|
||||
source_fields: Array.isArray(formula.source_fields) ? formula.source_fields : [],
|
||||
formula: formula.formula || '',
|
||||
child_doctype: tableField ? tableField.options : formula.child_doctype,
|
||||
enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
|
||||
apply_on: formula.apply_on || 'change',
|
||||
is_cumulative: !!formula.is_cumulative,
|
||||
description: formula.description || ''
|
||||
};
|
||||
});
|
||||
|
||||
this.debouncedOnChange();
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error importing table formulas:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export table formulas to JSON
|
||||
* @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON
|
||||
* @returns {string} JSON string
|
||||
* @public
|
||||
*/
|
||||
exportTableFormulas(prettyPrint = false) {
|
||||
try {
|
||||
return JSON.stringify(this.tableFormulas, null, prettyPrint ? 2 : 0);
|
||||
} catch (error) {
|
||||
console.error('Error exporting table formulas:', error);
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FormulaEditorTableRows;
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<void>}
|
||||
* @private
|
||||
*/
|
||||
async loadUniversalFields() {
|
||||
try {
|
||||
this.universalFields = [];
|
||||
|
||||
// First load the main doctype fields
|
||||
const mainDoctype = this.editor.options.doctype;
|
||||
const mainFields = await this.getDocTypeFields(mainDoctype);
|
||||
|
||||
// Add main doctype fields with doctype prefix (for clarity in formulas)
|
||||
mainFields.forEach(field => {
|
||||
this.universalFields.push({
|
||||
fieldname: `${mainDoctype.toLowerCase().replace(/ /g, '_')}.${field.fieldname}`,
|
||||
original_fieldname: field.fieldname,
|
||||
label: `${mainDoctype}: ${field.label || field.fieldname}`,
|
||||
doctype: mainDoctype,
|
||||
type: field.type,
|
||||
options: field.options,
|
||||
is_main_doctype: true
|
||||
});
|
||||
});
|
||||
|
||||
// Load fields for each linked doctype
|
||||
for (const link of this.linkedDoctypes) {
|
||||
if (!link.doctype) continue;
|
||||
|
||||
let fields = [];
|
||||
|
||||
// If specific fields are defined, use those
|
||||
if (Array.isArray(link.fields) && link.fields.length > 0) {
|
||||
fields = link.fields.map(f => {
|
||||
if (typeof f === 'string') {
|
||||
return { fieldname: f, label: f };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
} else {
|
||||
// Otherwise get all fields from the doctype
|
||||
fields = await this.getDocTypeFields(link.doctype);
|
||||
}
|
||||
|
||||
// Add to universal fields with prefix
|
||||
fields.forEach(field => {
|
||||
this.universalFields.push({
|
||||
fieldname: `${link.fieldPrefix}.${field.fieldname}`,
|
||||
original_fieldname: field.fieldname,
|
||||
label: `${link.label || link.doctype}: ${field.label || field.fieldname}`,
|
||||
doctype: link.doctype,
|
||||
type: field.type,
|
||||
options: field.options,
|
||||
linkInfo: {
|
||||
linkField: link.linkField,
|
||||
parentField: link.parentField,
|
||||
fieldPrefix: link.fieldPrefix
|
||||
},
|
||||
is_main_doctype: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Notify about change
|
||||
this.debouncedOnChange();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading universal fields:', error);
|
||||
showToast(__('Error loading universal fields'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fields for a doctype
|
||||
* @param {string} doctype - DocType name
|
||||
* @returns {Promise<Array>} List of fields
|
||||
* @private
|
||||
*/
|
||||
async getDocTypeFields(doctype) {
|
||||
try {
|
||||
// Check cache first
|
||||
if (this.fieldCache[doctype]) {
|
||||
return deepClone(this.fieldCache[doctype]);
|
||||
}
|
||||
|
||||
const fields = [];
|
||||
let meta;
|
||||
|
||||
// Try to get meta from cache
|
||||
meta = frappe.get_meta(doctype);
|
||||
|
||||
if (!meta) {
|
||||
// If not in cache, fetch it
|
||||
meta = await frappe.model.with_doctype(doctype);
|
||||
}
|
||||
|
||||
if (!meta || !meta.fields) {
|
||||
throw new Error(__('Could not fetch metadata for {0}', [doctype]));
|
||||
}
|
||||
|
||||
// Add fields from the DocType
|
||||
for (const field of meta.fields) {
|
||||
// Skip certain field types
|
||||
if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.push({
|
||||
fieldname: field.fieldname,
|
||||
label: field.label || field.fieldname,
|
||||
type: field.fieldtype,
|
||||
options: field.options,
|
||||
is_child_table: field.fieldtype === 'Table'
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the fields
|
||||
this.fieldCache[doctype] = deepClone(fields);
|
||||
|
||||
return fields;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error getting fields for doctype ${doctype}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all universal fields
|
||||
* @returns {Array} List of universal fields
|
||||
* @public
|
||||
*/
|
||||
getUniversalFields() {
|
||||
return deepClone(this.universalFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get linked doctypes
|
||||
* @returns {Array} List of linked doctypes
|
||||
* @public
|
||||
*/
|
||||
getLinkedDoctypes() {
|
||||
return deepClone(this.linkedDoctypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a linked doctype
|
||||
* @param {Object} linkData - Linked doctype data
|
||||
* @returns {string} New link ID
|
||||
* @public
|
||||
*/
|
||||
async addLinkedDoctype(linkData = {}) {
|
||||
if (!linkData.doctype) {
|
||||
throw new Error(__('DocType is required for linked doctype'));
|
||||
}
|
||||
|
||||
const newLink = {
|
||||
id: generateUniqueId('link'),
|
||||
label: linkData.label || linkData.doctype,
|
||||
doctype: linkData.doctype,
|
||||
linkField: linkData.linkField || null,
|
||||
parentField: linkData.parentField || null,
|
||||
fieldPrefix: linkData.fieldPrefix || linkData.doctype.toLowerCase().replace(/ /g, '_'),
|
||||
fields: Array.isArray(linkData.fields) ? deepClone(linkData.fields) : []
|
||||
};
|
||||
|
||||
this.linkedDoctypes.push(newLink);
|
||||
|
||||
// Reload universal fields
|
||||
await this.loadUniversalFields();
|
||||
|
||||
return newLink.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a linked doctype
|
||||
* @param {string} id - Link ID
|
||||
* @param {Object} updates - Properties to update
|
||||
* @returns {boolean} Success status
|
||||
* @public
|
||||
*/
|
||||
async updateLinkedDoctype(id, updates) {
|
||||
const index = this.linkedDoctypes.findIndex(l => l.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
// Clone the link
|
||||
const link = deepClone(this.linkedDoctypes[index]);
|
||||
|
||||
// Apply updates
|
||||
Object.keys(updates).forEach(key => {
|
||||
// Handle special properties
|
||||
if (key === 'fields' && Array.isArray(updates.fields)) {
|
||||
link.fields = deepClone(updates.fields);
|
||||
} else if (key !== 'id') { // Don't allow changing the ID
|
||||
link[key] = updates[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Update the link
|
||||
this.linkedDoctypes[index] = link;
|
||||
|
||||
// Reload universal fields
|
||||
await this.loadUniversalFields();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a linked doctype
|
||||
* @param {string} id - Link ID
|
||||
* @returns {boolean} Success status
|
||||
* @public
|
||||
*/
|
||||
async deleteLinkedDoctype(id) {
|
||||
const index = this.linkedDoctypes.findIndex(l => l.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
this.linkedDoctypes.splice(index, 1);
|
||||
|
||||
// Reload universal fields
|
||||
await this.loadUniversalFields();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for doctypes to link
|
||||
* @param {string} query - Search query
|
||||
* @param {number} [limit=20] - Maximum number of results
|
||||
* @returns {Promise<Array>} Search results
|
||||
* @public
|
||||
*/
|
||||
async searchDoctypes(query, limit = 20) {
|
||||
try {
|
||||
const doctypes = await frappe.db.get_list('DocType', {
|
||||
filters: {
|
||||
name: ['like', `%${query}%`],
|
||||
istable: 0,
|
||||
issingle: 0
|
||||
},
|
||||
fields: ['name', 'module'],
|
||||
limit: limit
|
||||
});
|
||||
|
||||
return doctypes.map(dt => ({
|
||||
value: dt.name,
|
||||
label: dt.name,
|
||||
description: dt.module
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error searching doctypes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get link fields for a doctype
|
||||
* @param {string} doctype - DocType name
|
||||
* @returns {Promise<Array>} List of link fields
|
||||
* @public
|
||||
*/
|
||||
async getLinkFields(doctype) {
|
||||
try {
|
||||
const fields = await this.getDocTypeFields(doctype);
|
||||
|
||||
// Filter to only link fields
|
||||
return fields.filter(f =>
|
||||
f.type === 'Link' ||
|
||||
f.type === 'Dynamic Link'
|
||||
).map(f => ({
|
||||
value: f.fieldname,
|
||||
label: f.label || f.fieldname,
|
||||
fieldtype: f.type,
|
||||
options: f.options
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error getting link fields for ${doctype}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential link fields between two doctypes
|
||||
* @param {string} sourceDoctype - Source DocType name
|
||||
* @param {string} targetDoctype - Target DocType name
|
||||
* @returns {Promise<Array>} List of potential link fields
|
||||
* @public
|
||||
*/
|
||||
async findPotentialLinks(sourceDoctype, targetDoctype) {
|
||||
try {
|
||||
const sourceFields = await this.getDocTypeFields(sourceDoctype);
|
||||
const potentialLinks = [];
|
||||
|
||||
// Find Link fields that point to the target doctype
|
||||
sourceFields.forEach(field => {
|
||||
if (field.type === 'Link' && field.options === targetDoctype) {
|
||||
potentialLinks.push({
|
||||
fieldname: field.fieldname,
|
||||
label: field.label || field.fieldname,
|
||||
linkType: 'direct',
|
||||
description: __('Direct link to {0}', [targetDoctype])
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Look for child tables that might link to the target
|
||||
sourceFields.forEach(field => {
|
||||
if (field.is_child_table) {
|
||||
potentialLinks.push({
|
||||
fieldname: field.fieldname,
|
||||
label: field.label || field.fieldname,
|
||||
linkType: 'child_table',
|
||||
description: __('Child table that might contain links to {0}', [targetDoctype]),
|
||||
childDoctype: field.options
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Also check target doctype for links back to source
|
||||
const targetFields = await this.getDocTypeFields(targetDoctype);
|
||||
|
||||
targetFields.forEach(field => {
|
||||
if (field.type === 'Link' && field.options === sourceDoctype) {
|
||||
potentialLinks.push({
|
||||
fieldname: field.fieldname,
|
||||
label: field.label || field.fieldname,
|
||||
linkType: 'reverse',
|
||||
description: __('Reverse link from {0}', [targetDoctype])
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return potentialLinks;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error finding potential links between ${sourceDoctype} and ${targetDoctype}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a formula to fetch data from a linked doctype
|
||||
* @param {string} linkId - Link ID
|
||||
* @param {string} fieldname - Field name to fetch
|
||||
* @returns {string} Generated formula
|
||||
* @public
|
||||
*/
|
||||
generateLinkFormula(linkId, fieldname) {
|
||||
const link = this.linkedDoctypes.find(l => l.id === linkId);
|
||||
if (!link) return '';
|
||||
|
||||
const targetField = this.universalFields.find(f =>
|
||||
f.doctype === link.doctype &&
|
||||
f.original_fieldname === fieldname
|
||||
);
|
||||
|
||||
if (!targetField) return '';
|
||||
|
||||
// If it's a direct link (parent to child)
|
||||
if (link.linkField) {
|
||||
return `
|
||||
// Get linked ${link.doctype} based on ${link.linkField}
|
||||
(function() {
|
||||
const linked = frappe.db.get_value('${link.doctype}', {
|
||||
name: doc.${link.linkField}
|
||||
}, '${fieldname}');
|
||||
|
||||
return linked ? linked.message.${fieldname} : null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
// If it's a child table link
|
||||
if (link.parentField) {
|
||||
return `
|
||||
// Get linked ${link.doctype} records where parent field matches
|
||||
(function() {
|
||||
const linked = frappe.db.get_list('${link.doctype}', {
|
||||
filters: {
|
||||
${link.parentField}: doc.name
|
||||
},
|
||||
fields: ['${fieldname}']
|
||||
});
|
||||
|
||||
return linked ? linked.map(item => item.${fieldname}) : [];
|
||||
})()`;
|
||||
}
|
||||
|
||||
// Generic case
|
||||
return `
|
||||
// Fetch data from ${link.doctype}
|
||||
frappe.db.get_value('${link.doctype}', null, '${fieldname}')`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a helper function to work with linked doctypes
|
||||
* @param {string} functionType - Type of function to generate (get_linked, set_linked, etc.)
|
||||
* @returns {string} Generated function code
|
||||
* @public
|
||||
*/
|
||||
generateHelperFunction(functionType) {
|
||||
switch (functionType) {
|
||||
case 'get_linked':
|
||||
return `
|
||||
/**
|
||||
* Get a value from a linked document
|
||||
* @param {string} doctype - DocType of linked document
|
||||
* @param {Object} filters - Filters to find the document
|
||||
* @param {string} field - Field to retrieve
|
||||
* @returns {*} Field value
|
||||
*/
|
||||
function get_linked(doctype, filters, field) {
|
||||
try {
|
||||
const result = frappe.db.get_value(doctype, filters, field);
|
||||
return result && result.message ? result.message[field] : null;
|
||||
} catch (error) {
|
||||
console.error(\`Error getting linked value: \${error}\`);
|
||||
return null;
|
||||
}
|
||||
}`;
|
||||
|
||||
case 'get_linked_list':
|
||||
return `
|
||||
/**
|
||||
* Get values from linked documents
|
||||
* @param {string} doctype - DocType of linked documents
|
||||
* @param {Object} filters - Filters to find the documents
|
||||
* @param {string|Array} fields - Fields to retrieve
|
||||
* @returns {Array} List of documents/values
|
||||
*/
|
||||
function get_linked_list(doctype, filters, fields) {
|
||||
try {
|
||||
return frappe.db.get_list(doctype, {
|
||||
filters: filters,
|
||||
fields: fields
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(\`Error getting linked list: \${error}\`);
|
||||
return [];
|
||||
}
|
||||
}`;
|
||||
|
||||
case 'set_linked':
|
||||
return `
|
||||
/**
|
||||
* Update a value in a linked document
|
||||
* @param {string} doctype - DocType of linked document
|
||||
* @param {string} name - Name of linked document
|
||||
* @param {Object} values - Values to update
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function set_linked(doctype, name, values) {
|
||||
try {
|
||||
frappe.db.set_value(doctype, name, values);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(\`Error setting linked value: \${error}\`);
|
||||
return false;
|
||||
}
|
||||
}`;
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preview of data from linked doctypes
|
||||
* @param {Object} doc - Current document
|
||||
* @returns {Promise<Object>} Preview data
|
||||
* @public
|
||||
*/
|
||||
async getLinkedDataPreview(doc) {
|
||||
if (!doc) return {};
|
||||
|
||||
const preview = {};
|
||||
|
||||
for (const link of this.linkedDoctypes) {
|
||||
try {
|
||||
preview[link.fieldPrefix] = await this.getLinkedDocTypeData(link, doc);
|
||||
} catch (error) {
|
||||
console.error(`Error getting preview for ${link.doctype}:`, error);
|
||||
preview[link.fieldPrefix] = { error: formatErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data for a linked doctype
|
||||
* @param {Object} link - Link configuration
|
||||
* @param {Object} doc - Current document
|
||||
* @returns {Promise<Object|Array>} Linked data
|
||||
* @private
|
||||
*/
|
||||
async getLinkedDocTypeData(link, doc) {
|
||||
// If it's a direct link (parent to child)
|
||||
if (link.linkField && doc[link.linkField]) {
|
||||
// Get single linked document
|
||||
const result = await frappe.db.get_value(
|
||||
link.doctype,
|
||||
{ name: doc[link.linkField] },
|
||||
'*'
|
||||
);
|
||||
|
||||
return result && result.message ? result.message : null;
|
||||
}
|
||||
|
||||
// If it's a child table link (child to parent)
|
||||
if (link.parentField) {
|
||||
// Get multiple linked documents
|
||||
const result = await frappe.db.get_list(
|
||||
link.doctype,
|
||||
{
|
||||
filters: {
|
||||
[link.parentField]: doc.name
|
||||
},
|
||||
fields: ['*'],
|
||||
limit: 10
|
||||
}
|
||||
);
|
||||
|
||||
return result || [];
|
||||
}
|
||||
|
||||
// If no specific link field is defined, return empty
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle field changes
|
||||
* @private
|
||||
*/
|
||||
handleChange() {
|
||||
if (typeof this.options.onChange === 'function') {
|
||||
this.options.onChange(this.universalFields);
|
||||
}
|
||||
|
||||
// Update editor fields to include universal fields
|
||||
if (this.editor && typeof this.editor.setFields === 'function') {
|
||||
// Convert universal fields to editor field format
|
||||
const editorFields = this.universalFields.map(field => ({
|
||||
fieldname: field.fieldname,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
options: field.options,
|
||||
universal: true,
|
||||
doctype: field.doctype
|
||||
}));
|
||||
|
||||
this.editor.setFields(editorFields);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import linked doctypes configuration from JSON
|
||||
* @param {string|Object} json - JSON string or object
|
||||
* @returns {Promise<boolean>} Success status
|
||||
* @public
|
||||
*/
|
||||
async importLinkedDoctypes(json) {
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (typeof json === 'string') {
|
||||
data = JSON.parse(json);
|
||||
} else if (typeof json === 'object' && json !== null) {
|
||||
data = json;
|
||||
} else {
|
||||
throw new Error(__('Invalid import data format'));
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(__('Import data must be an array'));
|
||||
}
|
||||
|
||||
// Validate each linked doctype
|
||||
const validLinks = data.filter(link =>
|
||||
typeof link === 'object' &&
|
||||
link !== null &&
|
||||
typeof link.doctype === 'string'
|
||||
);
|
||||
|
||||
if (validLinks.length === 0) {
|
||||
throw new Error(__('No valid linked doctypes found in import data'));
|
||||
}
|
||||
|
||||
// Replace linked doctypes
|
||||
this.linkedDoctypes = validLinks.map(link => ({
|
||||
id: link.id || generateUniqueId('link'),
|
||||
label: link.label || link.doctype,
|
||||
doctype: link.doctype,
|
||||
linkField: link.linkField || null,
|
||||
parentField: link.parentField || null,
|
||||
fieldPrefix: link.fieldPrefix || link.doctype.toLowerCase().replace(/ /g, '_'),
|
||||
fields: Array.isArray(link.fields) ? link.fields : []
|
||||
}));
|
||||
|
||||
// Reload universal fields
|
||||
await this.loadUniversalFields();
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error importing linked doctypes:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export linked doctypes configuration to JSON
|
||||
* @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON
|
||||
* @returns {string} JSON string
|
||||
* @public
|
||||
*/
|
||||
exportLinkedDoctypes(prettyPrint = false) {
|
||||
try {
|
||||
return JSON.stringify(this.linkedDoctypes, null, prettyPrint ? 2 : 0);
|
||||
} catch (error) {
|
||||
console.error('Error exporting linked doctypes:', error);
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FormulaEditorUniversalFields;
|
||||
|
|
@ -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
|
||||
};
|
||||
Loading…
Reference in New Issue