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