fixed bug with report field not having edit and delete button
This commit is contained in:
parent
30f878fc82
commit
8750e6b688
|
|
@ -735,10 +735,11 @@ def get_report_filters_dynamic(report_name):
|
||||||
|
|
||||||
# Находим и обновляем фильтр компании или добавляем его, если отсутствует
|
# Находим и обновляем фильтр компании или добавляем его, если отсутствует
|
||||||
company_filter = next((f for f in formatted_filters if f['fieldname'] == 'company'), None)
|
company_filter = next((f for f in formatted_filters if f['fieldname'] == 'company'), None)
|
||||||
|
|
||||||
if company_filter:
|
if company_filter:
|
||||||
company_filter['default'] = default_company
|
company_filter['default'] = default_company
|
||||||
company_filter['hidden'] = 1 # Скрываем фильтр компании всегда
|
company_filter['hidden'] = 1 # Скрываем фильтр компании всегда
|
||||||
|
company_filter['reqd'] = 0 # Важно! Не делаем его обязательным для пользовательского ввода
|
||||||
else:
|
else:
|
||||||
# Добавляем скрытый фильтр компании, если его нет
|
# Добавляем скрытый фильтр компании, если его нет
|
||||||
formatted_filters.append({
|
formatted_filters.append({
|
||||||
|
|
@ -747,10 +748,10 @@ def get_report_filters_dynamic(report_name):
|
||||||
'fieldtype': 'Link',
|
'fieldtype': 'Link',
|
||||||
'options': 'Company',
|
'options': 'Company',
|
||||||
'default': default_company,
|
'default': default_company,
|
||||||
'reqd': 1,
|
'reqd': 0, # Не делаем обязательным
|
||||||
'hidden': 1 # Скрываем фильтр компании всегда
|
'hidden': 1 # Скрываем фильтр компании всегда
|
||||||
})
|
})
|
||||||
|
|
||||||
return formatted_filters
|
return formatted_filters
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -34,7 +34,7 @@ frappe.ui.form.on('Report Simulator', {
|
||||||
// Функция для загрузки фильтров отчета
|
// Функция для загрузки фильтров отчета
|
||||||
function load_report_filters(frm) {
|
function load_report_filters(frm) {
|
||||||
frappe.call({
|
frappe.call({
|
||||||
method: 'formula_editor.formula_editor.doctype.report_simulator.report_simulator.get_report_filters_dynamic',
|
method: 'formula_editor_js.formula_editor_js.doctype.report_simulator.report_simulator.get_report_filters_dynamic',
|
||||||
args: {
|
args: {
|
||||||
report_name: frm.doc.report
|
report_name: frm.doc.report
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ frappe.listview_settings['Report Simulator'] = {
|
||||||
|
|
||||||
// Выполняем отчет
|
// Выполняем отчет
|
||||||
frappe.call({
|
frappe.call({
|
||||||
method: 'formula_editor.formula_editor.doctype.report_simulator.report_simulator.run_report_simulation',
|
method: 'formula_editor_js.formula_editor_js.doctype.report_simulator.report_simulator.run_report_simulation',
|
||||||
args: {
|
args: {
|
||||||
report_name: values.report,
|
report_name: values.report,
|
||||||
filters_json: values.filters_json
|
filters_json: values.filters_json
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
app_name = "formula_editor"
|
app_name = "formula_editor"
|
||||||
app_title = "Formula Editor"
|
app_title = "Formula Editor"
|
||||||
app_publisher = "Jey ERP"
|
app_publisher = "Your Company"
|
||||||
app_description = "App for adding formulas for any doctype"
|
app_description = "Advanced formula editing capabilities for Frappe/ERPNext"
|
||||||
app_email = "info@jey_erp.az"
|
app_email = "your.email@example.com"
|
||||||
app_license = "unlicense"
|
app_license = "MIT"
|
||||||
|
|
||||||
# Apps
|
|
||||||
# ------------------
|
|
||||||
|
|
||||||
# required_apps = []
|
|
||||||
|
|
||||||
# Each item in the list will be shown as an app in the apps page
|
|
||||||
# add_to_apps_screen = [
|
|
||||||
# {
|
|
||||||
# "name": "formula_editor",
|
|
||||||
# "logo": "/assets/formula_editor/logo.png",
|
|
||||||
# "title": "Formula Editor",
|
|
||||||
# "route": "/formula_editor",
|
|
||||||
# "has_permission": "formula_editor.api.permission.has_app_permission"
|
|
||||||
# }
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# Includes in <head>
|
# Includes in <head>
|
||||||
# ------------------
|
# ------------------
|
||||||
|
|
||||||
# include js, css files in header of desk.html
|
# include js, css files in header of desk.html
|
||||||
# app_include_css = "/assets/formula_editor/css/formula_editor.css"
|
app_include_css = [
|
||||||
# app_include_js = "/assets/formula_editor/js/formula_editor.js"
|
"/assets/formula_editor/css/formula_editor.css"
|
||||||
|
]
|
||||||
|
app_include_js = [
|
||||||
|
"/assets/formula_editor/js/core.js",
|
||||||
|
"/assets/formula_editor/js/index.js",
|
||||||
|
"/assets/formula_editor/js/report_fields.js",
|
||||||
|
"/assets/formula_editor/js/table_row_formulas.js",
|
||||||
|
"/assets/formula_editor/js/triggers.js",
|
||||||
|
"/assets/formula_editor/js/ui.js",
|
||||||
|
"/assets/formula_editor/js/universal_fields.js",
|
||||||
|
"/assets/formula_editor/js/utils.js"
|
||||||
|
]
|
||||||
|
|
||||||
# include js, css files in header of web template
|
# include js, css files in header of web template
|
||||||
# web_include_css = "/assets/formula_editor/css/formula_editor.css"
|
# web_include_css = "/assets/formula_editor/css/formula_editor.css"
|
||||||
|
|
@ -43,15 +38,12 @@ app_license = "unlicense"
|
||||||
# page_js = {"page" : "public/js/file.js"}
|
# page_js = {"page" : "public/js/file.js"}
|
||||||
|
|
||||||
# include js in doctype views
|
# include js in doctype views
|
||||||
# doctype_js = {"doctype" : "public/js/doctype.js"}
|
doctype_js = {
|
||||||
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
|
"DocType": "public/js/doctype_formula_editor.js",
|
||||||
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
|
}
|
||||||
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
|
|
||||||
|
|
||||||
# Svg Icons
|
# include js in doctype list views
|
||||||
# ------------------
|
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
|
||||||
# include app icons in desk
|
|
||||||
# app_include_icons = "formula_editor/public/icons.svg"
|
|
||||||
|
|
||||||
# Home Pages
|
# Home Pages
|
||||||
# ----------
|
# ----------
|
||||||
|
|
@ -61,7 +53,7 @@ app_license = "unlicense"
|
||||||
|
|
||||||
# website user home page (by Role)
|
# website user home page (by Role)
|
||||||
# role_home_page = {
|
# role_home_page = {
|
||||||
# "Role": "home_page"
|
# "Role": "home_page"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Generators
|
# Generators
|
||||||
|
|
@ -70,16 +62,13 @@ app_license = "unlicense"
|
||||||
# automatically create page for each record of this doctype
|
# automatically create page for each record of this doctype
|
||||||
# website_generators = ["Web Page"]
|
# website_generators = ["Web Page"]
|
||||||
|
|
||||||
# automatically load and sync documents of this doctype from downstream apps
|
|
||||||
# importable_doctypes = [doctype_1]
|
|
||||||
|
|
||||||
# Jinja
|
# Jinja
|
||||||
# ----------
|
# ----------
|
||||||
|
|
||||||
# add methods and filters to jinja environment
|
# add methods and filters to jinja environment
|
||||||
# jinja = {
|
# jinja = {
|
||||||
# "methods": "formula_editor.utils.jinja_methods",
|
# "methods": "formula_editor.utils.jinja_methods",
|
||||||
# "filters": "formula_editor.utils.jinja_filters"
|
# "filters": "formula_editor.utils.jinja_filters"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
@ -94,22 +83,6 @@ app_license = "unlicense"
|
||||||
# before_uninstall = "formula_editor.uninstall.before_uninstall"
|
# before_uninstall = "formula_editor.uninstall.before_uninstall"
|
||||||
# after_uninstall = "formula_editor.uninstall.after_uninstall"
|
# after_uninstall = "formula_editor.uninstall.after_uninstall"
|
||||||
|
|
||||||
# Integration Setup
|
|
||||||
# ------------------
|
|
||||||
# To set up dependencies/integrations with other apps
|
|
||||||
# Name of the app being installed is passed as an argument
|
|
||||||
|
|
||||||
# before_app_install = "formula_editor.utils.before_app_install"
|
|
||||||
# after_app_install = "formula_editor.utils.after_app_install"
|
|
||||||
|
|
||||||
# Integration Cleanup
|
|
||||||
# -------------------
|
|
||||||
# To clean up dependencies/integrations with other apps
|
|
||||||
# Name of the app being uninstalled is passed as an argument
|
|
||||||
|
|
||||||
# before_app_uninstall = "formula_editor.utils.before_app_uninstall"
|
|
||||||
# after_app_uninstall = "formula_editor.utils.after_app_uninstall"
|
|
||||||
|
|
||||||
# Desk Notifications
|
# Desk Notifications
|
||||||
# ------------------
|
# ------------------
|
||||||
# See frappe.core.notifications.get_notification_config
|
# See frappe.core.notifications.get_notification_config
|
||||||
|
|
@ -121,11 +94,11 @@ app_license = "unlicense"
|
||||||
# Permissions evaluated in scripted ways
|
# Permissions evaluated in scripted ways
|
||||||
|
|
||||||
# permission_query_conditions = {
|
# permission_query_conditions = {
|
||||||
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
|
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
# has_permission = {
|
# has_permission = {
|
||||||
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# DocType Class
|
# DocType Class
|
||||||
|
|
@ -133,7 +106,7 @@ app_license = "unlicense"
|
||||||
# Override standard doctype classes
|
# Override standard doctype classes
|
||||||
|
|
||||||
# override_doctype_class = {
|
# override_doctype_class = {
|
||||||
# "ToDo": "custom_app.overrides.CustomToDo"
|
# "ToDo": "custom_app.overrides.CustomToDo"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Document Events
|
# Document Events
|
||||||
|
|
@ -150,21 +123,21 @@ doc_events = {
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
# scheduler_events = {
|
# scheduler_events = {
|
||||||
# "all": [
|
# "all": [
|
||||||
# "formula_editor.tasks.all"
|
# "formula_editor.tasks.all"
|
||||||
# ],
|
# ],
|
||||||
# "daily": [
|
# "daily": [
|
||||||
# "formula_editor.tasks.daily"
|
# "formula_editor.tasks.daily"
|
||||||
# ],
|
# ],
|
||||||
# "hourly": [
|
# "hourly": [
|
||||||
# "formula_editor.tasks.hourly"
|
# "formula_editor.tasks.hourly"
|
||||||
# ],
|
# ],
|
||||||
# "weekly": [
|
# "weekly": [
|
||||||
# "formula_editor.tasks.weekly"
|
# "formula_editor.tasks.weekly"
|
||||||
# ],
|
# ],
|
||||||
# "monthly": [
|
# "monthly": [
|
||||||
# "formula_editor.tasks.monthly"
|
# "formula_editor.tasks.monthly"
|
||||||
# ],
|
# ],
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
|
|
@ -176,14 +149,14 @@ doc_events = {
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
#
|
#
|
||||||
# override_whitelisted_methods = {
|
# override_whitelisted_methods = {
|
||||||
# "frappe.desk.doctype.event.event.get_events": "formula_editor.event.get_events"
|
# "frappe.desk.doctype.event.event.get_events": "formula_editor.event.get_events"
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
# each overriding function accepts a `data` argument;
|
# each overriding function accepts a `data` argument;
|
||||||
# generated from the base implementation of the doctype dashboard,
|
# generated from the base implementation of the doctype dashboard,
|
||||||
# along with any modifications made in other Frappe apps
|
# along with any modifications made in other Frappe apps
|
||||||
# override_doctype_dashboards = {
|
# override_doctype_dashboards = {
|
||||||
# "Task": "formula_editor.task.get_dashboard_data"
|
# "Task": "formula_editor.task.get_dashboard_data"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# exempt linked doctypes from being automatically cancelled
|
# exempt linked doctypes from being automatically cancelled
|
||||||
|
|
@ -209,37 +182,37 @@ doc_events = {
|
||||||
# --------------------
|
# --------------------
|
||||||
|
|
||||||
# user_data_fields = [
|
# user_data_fields = [
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_1}",
|
# "doctype": "{doctype_1}",
|
||||||
# "filter_by": "{filter_by}",
|
# "filter_by": "{filter_by}",
|
||||||
# "redact_fields": ["{field_1}", "{field_2}"],
|
# "redact_fields": ["{field_1}", "{field_2}"],
|
||||||
# "partial": 1,
|
# "partial": 1,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_2}",
|
# "doctype": "{doctype_2}",
|
||||||
# "filter_by": "{filter_by}",
|
# "filter_by": "{filter_by}",
|
||||||
# "partial": 1,
|
# "partial": 1,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_3}",
|
# "doctype": "{doctype_3}",
|
||||||
# "strict": False,
|
# "strict": False,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_4}"
|
# "doctype": "{doctype_4}"
|
||||||
# }
|
# }
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
# Authentication and authorization
|
# Authentication and authorization
|
||||||
# --------------------------------
|
# --------------------------------
|
||||||
|
|
||||||
# auth_hooks = [
|
# auth_hooks = [
|
||||||
# "formula_editor.auth.validate"
|
# "formula_editor.auth.validate"
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
# Automatically update python controller files with type annotations for this app.
|
# Translation
|
||||||
# export_python_type_annotations = True
|
# --------------------------------
|
||||||
|
|
||||||
# default_log_clearing_doctypes = {
|
|
||||||
# "Logging DocType Name": 30 # days to retain logs
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
# Make link fields search translated document names for these doctypes
|
||||||
|
# Recommended only for DocTypes which have limited documents with untranslated names
|
||||||
|
# For example: Role, Gender, etc.
|
||||||
|
# translated_search_doctypes = []
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
/**
|
||||||
|
* Formula Editor Styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
.formula-editor-container {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-container.has-error {
|
||||||
|
border-color: var(--red-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-top {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-code-section {
|
||||||
|
flex: 2;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-panels-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-bottom {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-code-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-code {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-search {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--bg-light-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header:hover {
|
||||||
|
background-color: var(--bg-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-count {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-content {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed .group-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px 4px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item:hover {
|
||||||
|
background-color: var(--bg-light-gray);
|
||||||
|
border-left-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-type {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-item {
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-item:hover,
|
||||||
|
.category-item.selected {
|
||||||
|
background-color: var(--bg-light-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-item.selected {
|
||||||
|
border-left-color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-item:hover {
|
||||||
|
background-color: var(--bg-light-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-syntax {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-description {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-error-panel {
|
||||||
|
color: var(--red-500);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-error-panel i {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-error-panel.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-btn {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-list-container {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-list-item {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-field {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-code {
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--bg-light-gray);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-description {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-toggle-btn.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-calculated-field {
|
||||||
|
background-color: rgba(var(--primary-rgb), 0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-calculated-field::after {
|
||||||
|
content: "ƒ";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-container.read-only .formula-editor-code,
|
||||||
|
.formula-editor-container.read-only .formula-editor-textarea {
|
||||||
|
background-color: var(--bg-light-gray);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-editor-report-btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
background-color: var(--bg-light-gray);
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For trigger mode */
|
||||||
|
.formula-editor-container.trigger-mode .formula-editor-panels-section {
|
||||||
|
flex: 1.5;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For report mode */
|
||||||
|
.formula-editor-container.report-mode .formula-editor-code-section {
|
||||||
|
flex: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden elements */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,466 @@
|
||||||
|
/**
|
||||||
|
* Formula Editor Core
|
||||||
|
* Contains the main FormulaEditor class and core functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
safeEval,
|
||||||
|
validateFormula,
|
||||||
|
extractReferencedFields,
|
||||||
|
generateUniqueId,
|
||||||
|
debounce,
|
||||||
|
formatErrorMessage,
|
||||||
|
showToast,
|
||||||
|
isEmpty,
|
||||||
|
deepClone,
|
||||||
|
safeParseJSON
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
class FormulaEditor {
|
||||||
|
/**
|
||||||
|
* Create a new FormulaEditor instance
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {string} options.parent - Parent element selector or element
|
||||||
|
* @param {string} options.doctype - DocType name
|
||||||
|
* @param {string} [options.fieldname] - Field name
|
||||||
|
* @param {Object} [options.doc] - Current document
|
||||||
|
* @param {Function} [options.on_change] - Change callback
|
||||||
|
* @param {Array} [options.custom_fields] - Additional custom fields
|
||||||
|
* @param {boolean} [options.is_report] - Whether this is used in a report
|
||||||
|
* @param {boolean} [options.is_trigger] - Whether this is used for triggers
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.options = Object.assign({
|
||||||
|
parent: null,
|
||||||
|
doctype: null,
|
||||||
|
fieldname: null,
|
||||||
|
doc: null,
|
||||||
|
on_change: null,
|
||||||
|
custom_fields: [],
|
||||||
|
is_report: false,
|
||||||
|
is_trigger: false
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
// Validate required options
|
||||||
|
if (!this.options.parent) {
|
||||||
|
throw new Error(__('Parent element is required for Formula Editor'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.options.doctype) {
|
||||||
|
throw new Error(__('DocType is required for Formula Editor'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize properties
|
||||||
|
this.initialized = false;
|
||||||
|
this.formula = '';
|
||||||
|
this.fields = [];
|
||||||
|
this.errors = [];
|
||||||
|
this.meta = null;
|
||||||
|
this.parent = typeof this.options.parent === 'string'
|
||||||
|
? document.querySelector(this.options.parent)
|
||||||
|
: this.options.parent;
|
||||||
|
this.editor_id = generateUniqueId('formula-editor');
|
||||||
|
|
||||||
|
// Create debounced functions
|
||||||
|
this.debouncedValidate = debounce(this.validate.bind(this), 500);
|
||||||
|
this.debouncedOnChange = debounce(this.handleChange.bind(this), 300);
|
||||||
|
|
||||||
|
// Internal data storage
|
||||||
|
this._cached_fields = null;
|
||||||
|
this._field_options = null;
|
||||||
|
this._referenced_fields = [];
|
||||||
|
|
||||||
|
// Initialize the editor
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the editor
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
// Fetch metadata for the DocType
|
||||||
|
await this.fetchMeta();
|
||||||
|
|
||||||
|
// Get available fields
|
||||||
|
await this.getFields();
|
||||||
|
|
||||||
|
// Create the UI elements
|
||||||
|
this.createElements();
|
||||||
|
|
||||||
|
// Bind events
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
// Initialize with current value if available
|
||||||
|
if (this.options.doc && this.options.fieldname) {
|
||||||
|
this.setValue(this.options.doc[this.options.fieldname] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing Formula Editor:', error);
|
||||||
|
this.showError(__('Failed to initialize Formula Editor'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch DocType metadata
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async fetchMeta() {
|
||||||
|
try {
|
||||||
|
this.meta = frappe.get_meta(this.options.doctype);
|
||||||
|
|
||||||
|
if (!this.meta) {
|
||||||
|
// If meta is not in cache, fetch it
|
||||||
|
this.meta = await frappe.model.with_doctype(this.options.doctype);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching metadata:', error);
|
||||||
|
throw new Error(__('Could not fetch metadata for {0}', [this.options.doctype]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available fields for the formula
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async getFields() {
|
||||||
|
// Return cached fields if available
|
||||||
|
if (this._cached_fields) {
|
||||||
|
this.fields = deepClone(this._cached_fields);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fields = [];
|
||||||
|
|
||||||
|
// Add standard fields
|
||||||
|
this.addStandardFields(fields);
|
||||||
|
|
||||||
|
// Add fields from the DocType
|
||||||
|
this.addDocTypeFields(fields);
|
||||||
|
|
||||||
|
// Add custom fields if provided
|
||||||
|
if (Array.isArray(this.options.custom_fields)) {
|
||||||
|
fields.push(...this.options.custom_fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save and cache the fields
|
||||||
|
this.fields = fields;
|
||||||
|
this._cached_fields = deepClone(fields);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting fields:', error);
|
||||||
|
throw new Error(__('Could not get fields for Formula Editor'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add standard fields like 'doc', etc.
|
||||||
|
* @param {Array} fields - Fields array to add to
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
addStandardFields(fields) {
|
||||||
|
// Add 'doc' as a standard field
|
||||||
|
fields.push({
|
||||||
|
fieldname: 'doc',
|
||||||
|
label: __('Current Document'),
|
||||||
|
type: 'Object',
|
||||||
|
standard: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add other standard fields based on context
|
||||||
|
if (this.options.is_report) {
|
||||||
|
fields.push({
|
||||||
|
fieldname: 'row',
|
||||||
|
label: __('Current Row'),
|
||||||
|
type: 'Object',
|
||||||
|
standard: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add fields from the DocType
|
||||||
|
* @param {Array} fields - Fields array to add to
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
addDocTypeFields(fields) {
|
||||||
|
if (!this.meta || !this.meta.fields) return;
|
||||||
|
|
||||||
|
// Add all fields from the DocType
|
||||||
|
for (const field of this.meta.fields) {
|
||||||
|
// Skip certain field types
|
||||||
|
if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
label: field.label || field.fieldname,
|
||||||
|
type: field.fieldtype,
|
||||||
|
options: field.options,
|
||||||
|
table_fields: this.getTableFields(field),
|
||||||
|
is_child_table: field.fieldtype === 'Table'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fields for a Table type field
|
||||||
|
* @param {Object} field - The table field
|
||||||
|
* @returns {Array} Child table fields
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getTableFields(field) {
|
||||||
|
if (field.fieldtype !== 'Table' || !field.options) return [];
|
||||||
|
|
||||||
|
const childFields = [];
|
||||||
|
const childMeta = frappe.get_meta(field.options);
|
||||||
|
|
||||||
|
if (!childMeta || !childMeta.fields) return [];
|
||||||
|
|
||||||
|
for (const childField of childMeta.fields) {
|
||||||
|
// Skip certain field types
|
||||||
|
if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(childField.fieldtype)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
childFields.push({
|
||||||
|
fieldname: childField.fieldname,
|
||||||
|
label: childField.label || childField.fieldname,
|
||||||
|
type: childField.fieldtype,
|
||||||
|
options: childField.options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return childFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create UI elements for the editor
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
createElements() {
|
||||||
|
// This is a placeholder - UI creation will be moved to ui.js
|
||||||
|
// Here we just create the basic container
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.id = this.editor_id;
|
||||||
|
this.container.className = 'formula-editor-container';
|
||||||
|
|
||||||
|
// Create a simple textarea for now (will be replaced by proper UI)
|
||||||
|
this.textarea = document.createElement('textarea');
|
||||||
|
this.textarea.className = 'formula-editor-input';
|
||||||
|
this.textarea.placeholder = __('Enter your formula here...');
|
||||||
|
|
||||||
|
// Error display element
|
||||||
|
this.errorEl = document.createElement('div');
|
||||||
|
this.errorEl.className = 'formula-editor-error hidden';
|
||||||
|
|
||||||
|
// Append to container
|
||||||
|
this.container.appendChild(this.textarea);
|
||||||
|
this.container.appendChild(this.errorEl);
|
||||||
|
|
||||||
|
// Append to parent
|
||||||
|
this.parent.appendChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind events to the editor elements
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
bindEvents() {
|
||||||
|
if (!this.textarea) return;
|
||||||
|
|
||||||
|
// Input event for formula changes
|
||||||
|
this.textarea.addEventListener('input', () => {
|
||||||
|
this.formula = this.textarea.value;
|
||||||
|
this.debouncedValidate();
|
||||||
|
this.debouncedOnChange();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus/blur events
|
||||||
|
this.textarea.addEventListener('focus', this.handleFocus.bind(this));
|
||||||
|
this.textarea.addEventListener('blur', this.handleBlur.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle focus event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleFocus() {
|
||||||
|
this.container.classList.add('focused');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle blur event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleBlur() {
|
||||||
|
this.container.classList.remove('focused');
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle formula change
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleChange() {
|
||||||
|
if (typeof this.options.on_change === 'function') {
|
||||||
|
this.options.on_change(this.formula, this._referenced_fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the formula value
|
||||||
|
* @param {string} value - Formula to set
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
setValue(value) {
|
||||||
|
this.formula = value || '';
|
||||||
|
|
||||||
|
if (this.textarea) {
|
||||||
|
this.textarea.value = this.formula;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current formula value
|
||||||
|
* @returns {string} Current formula
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getValue() {
|
||||||
|
return this.formula;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the current formula
|
||||||
|
* @returns {boolean} Whether the formula is valid
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
validate() {
|
||||||
|
// Clear previous errors
|
||||||
|
this.errors = [];
|
||||||
|
|
||||||
|
// Skip validation for empty formula
|
||||||
|
if (!this.formula.trim()) {
|
||||||
|
this.hideError();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate formula syntax
|
||||||
|
const validation = validateFormula(this.formula);
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
this.errors.push(validation.error);
|
||||||
|
this.showError(validation.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get referenced fields
|
||||||
|
this._referenced_fields = extractReferencedFields(
|
||||||
|
this.formula,
|
||||||
|
this.fields.map(f => f.fieldname)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hide error if validation passed
|
||||||
|
this.hideError();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error message
|
||||||
|
* @param {string} message - Error message
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
showError(message) {
|
||||||
|
if (!this.errorEl) return;
|
||||||
|
|
||||||
|
this.errorEl.textContent = formatErrorMessage(message);
|
||||||
|
this.errorEl.classList.remove('hidden');
|
||||||
|
this.container.classList.add('has-error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide error message
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
hideError() {
|
||||||
|
if (!this.errorEl) return;
|
||||||
|
|
||||||
|
this.errorEl.textContent = '';
|
||||||
|
this.errorEl.classList.add('hidden');
|
||||||
|
this.container.classList.remove('has-error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate the current formula
|
||||||
|
* @param {Object} [context] - Additional context for evaluation
|
||||||
|
* @returns {*} Result of the evaluation
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
evaluate(context = {}) {
|
||||||
|
if (!this.formula.trim()) return null;
|
||||||
|
|
||||||
|
// Validate before evaluation
|
||||||
|
if (!this.validate()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create evaluation context
|
||||||
|
const evalContext = {
|
||||||
|
doc: this.options.doc || {},
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return safeEval(this.formula, evalContext);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error evaluating formula:', error);
|
||||||
|
this.showError(error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the fields referenced in the current formula
|
||||||
|
* @returns {Array} Array of referenced field names
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getReferencedFields() {
|
||||||
|
return [...this._referenced_fields];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the editor and clean up
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
// Remove event listeners
|
||||||
|
if (this.textarea) {
|
||||||
|
this.textarea.removeEventListener('input', this.debouncedOnChange);
|
||||||
|
this.textarea.removeEventListener('focus', this.handleFocus);
|
||||||
|
this.textarea.removeEventListener('blur', this.handleBlur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove DOM elements
|
||||||
|
if (this.container && this.container.parentNode) {
|
||||||
|
this.container.parentNode.removeChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up properties
|
||||||
|
this.initialized = false;
|
||||||
|
this._cached_fields = null;
|
||||||
|
this._field_options = null;
|
||||||
|
this._referenced_fields = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormulaEditor;
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
/**
|
||||||
|
* Formula Editor
|
||||||
|
* Main entry point for the Formula Editor module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import FormulaEditor from './core';
|
||||||
|
import FormulaEditorUI from './ui';
|
||||||
|
import FormulaEditorTriggers from './triggers';
|
||||||
|
import FormulaEditorUniversalFields from './universal_fields';
|
||||||
|
import FormulaEditorTableRows from './table_row_formulas';
|
||||||
|
import FormulaEditorReportFields from './report_fields';
|
||||||
|
import * as utils from './utils';
|
||||||
|
|
||||||
|
// Create the top-level namespace for the formula editor
|
||||||
|
frappe.formula_editor = {
|
||||||
|
// Core classes
|
||||||
|
FormulaEditor,
|
||||||
|
FormulaEditorUI,
|
||||||
|
FormulaEditorTriggers,
|
||||||
|
FormulaEditorUniversalFields,
|
||||||
|
FormulaEditorTableRows,
|
||||||
|
FormulaEditorReportFields,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
utils,
|
||||||
|
|
||||||
|
// Instances cache
|
||||||
|
_instances: {},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new formula editor instance
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @returns {FormulaEditor} Formula editor instance
|
||||||
|
*/
|
||||||
|
create(options) {
|
||||||
|
const editor = new FormulaEditor(options);
|
||||||
|
const id = options.id || `formula_editor_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
this._instances[id] = editor;
|
||||||
|
return editor;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a formula editor instance by ID
|
||||||
|
* @param {string} id - Instance ID
|
||||||
|
* @returns {FormulaEditor|null} Formula editor instance or null if not found
|
||||||
|
*/
|
||||||
|
get(id) {
|
||||||
|
return this._instances[id] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy a formula editor instance
|
||||||
|
* @param {string} id - Instance ID
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
*/
|
||||||
|
destroy(id) {
|
||||||
|
const editor = this.get(id);
|
||||||
|
if (!editor) return false;
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
delete this._instances[id];
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize formula editor for a form field
|
||||||
|
* @param {frappe.ui.form.Field} field - Form field to initialize editor for
|
||||||
|
* @param {Object} [options={}] - Additional options
|
||||||
|
* @returns {FormulaEditor} Formula editor instance
|
||||||
|
*/
|
||||||
|
initForField(field, options = {}) {
|
||||||
|
if (!field || !field.frm || !field.df) {
|
||||||
|
console.error('Invalid field provided to initForField');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldId = `${field.frm.doctype}_${field.df.fieldname}`;
|
||||||
|
|
||||||
|
// Check if editor already exists for this field
|
||||||
|
const existingEditor = this.get(fieldId);
|
||||||
|
if (existingEditor) return existingEditor;
|
||||||
|
|
||||||
|
// Create wrapper element
|
||||||
|
const $wrapper = $('<div class="formula-editor-field-wrapper"></div>');
|
||||||
|
field.$wrapper.find('.control-input-wrapper').append($wrapper);
|
||||||
|
|
||||||
|
// Create editor
|
||||||
|
const editorOptions = {
|
||||||
|
id: fieldId,
|
||||||
|
parent: $wrapper[0],
|
||||||
|
doctype: field.frm.doctype,
|
||||||
|
fieldname: field.df.fieldname,
|
||||||
|
doc: field.frm.doc,
|
||||||
|
on_change: (value, referencedFields) => {
|
||||||
|
field.set_input(value);
|
||||||
|
|
||||||
|
// Store referenced fields to update formula when they change
|
||||||
|
if (Array.isArray(referencedFields)) {
|
||||||
|
field._formulaReferencedFields = referencedFields;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = this.create(editorOptions);
|
||||||
|
|
||||||
|
// Set up field change listener to keep formula in sync
|
||||||
|
field._originalRefresh = field.refresh;
|
||||||
|
field.refresh = () => {
|
||||||
|
const result = field._originalRefresh();
|
||||||
|
|
||||||
|
// Update editor value if it doesn't match field value
|
||||||
|
if (editor.getValue() !== field.value) {
|
||||||
|
editor.setValue(field.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up listener for referenced fields
|
||||||
|
this._setupReferencedFieldsListeners(field.frm, field);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up listeners for fields referenced in a formula
|
||||||
|
* @param {frappe.ui.form.Form} frm - Form instance
|
||||||
|
* @param {frappe.ui.form.Field} formulaField - Formula field
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_setupReferencedFieldsListeners(frm, formulaField) {
|
||||||
|
if (!frm || !formulaField || !formulaField._formulaReferencedFields) return;
|
||||||
|
|
||||||
|
const referencedFields = formulaField._formulaReferencedFields;
|
||||||
|
|
||||||
|
referencedFields.forEach(fieldname => {
|
||||||
|
const field = frm.fields_dict[fieldname];
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
// Skip if already set up
|
||||||
|
if (field._formulaListenerSetup) return;
|
||||||
|
|
||||||
|
// Set up change listener
|
||||||
|
const originalOnChange = field.df.change;
|
||||||
|
field.df.change = () => {
|
||||||
|
if (typeof originalOnChange === 'function') {
|
||||||
|
originalOnChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all formula fields that reference this field
|
||||||
|
Object.values(frm.fields_dict).forEach(f => {
|
||||||
|
if (f._formulaReferencedFields &&
|
||||||
|
f._formulaReferencedFields.includes(fieldname) &&
|
||||||
|
f !== formulaField) {
|
||||||
|
|
||||||
|
// Get the editor
|
||||||
|
const editorId = `${frm.doctype}_${f.df.fieldname}`;
|
||||||
|
const editor = frappe.formula_editor.get(editorId);
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
// Re-evaluate the formula
|
||||||
|
const result = editor.evaluate();
|
||||||
|
if (result !== undefined) {
|
||||||
|
f.set_input(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
field._formulaListenerSetup = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize formula editor for a report
|
||||||
|
* @param {frappe.views.ReportView|frappe.query_reports.QueryReport} report - Report instance
|
||||||
|
* @param {string} reportName - Report name
|
||||||
|
* @param {string} [reportType='report'] - Report type
|
||||||
|
* @returns {FormulaEditorReportFields} Report fields handler instance
|
||||||
|
*/
|
||||||
|
initForReport(report, reportName, reportType = 'report') {
|
||||||
|
if (!report || !reportName) {
|
||||||
|
console.error('Invalid report provided to initForReport');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportId = `${reportType}_${reportName}`;
|
||||||
|
|
||||||
|
// Check if already initialized
|
||||||
|
if (report._formulaEditor) return report._formulaEditor;
|
||||||
|
|
||||||
|
// Create a minimal editor instance for the report
|
||||||
|
const editorOptions = {
|
||||||
|
id: reportId,
|
||||||
|
doctype: reportName,
|
||||||
|
is_report: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = new FormulaEditor(editorOptions);
|
||||||
|
|
||||||
|
// Create report fields handler
|
||||||
|
const reportFields = new FormulaEditorReportFields({
|
||||||
|
editor: editor,
|
||||||
|
reportFormulas: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Integrate with the report
|
||||||
|
reportFields.integrateWithReport(report, reportName, reportType);
|
||||||
|
|
||||||
|
// Store reference on the report
|
||||||
|
report._formulaEditor = reportFields;
|
||||||
|
|
||||||
|
return reportFields;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize table row formulas for a form
|
||||||
|
* @param {frappe.ui.form.Form} frm - Form instance
|
||||||
|
* @param {Object} [options={}] - Additional options
|
||||||
|
* @returns {FormulaEditorTableRows} Table rows handler instance
|
||||||
|
*/
|
||||||
|
initTableFormulas(frm, options = {}) {
|
||||||
|
if (!frm || !frm.doctype) {
|
||||||
|
console.error('Invalid form provided to initTableFormulas');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formId = `table_formulas_${frm.doctype}`;
|
||||||
|
|
||||||
|
// Check if already initialized
|
||||||
|
if (frm._tableFormulasEditor) return frm._tableFormulasEditor;
|
||||||
|
|
||||||
|
// Create a minimal editor instance for the form
|
||||||
|
const editorOptions = {
|
||||||
|
id: formId,
|
||||||
|
doctype: frm.doctype,
|
||||||
|
doc: frm.doc
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = new FormulaEditor(editorOptions);
|
||||||
|
|
||||||
|
// Create table rows handler
|
||||||
|
const tableRows = new FormulaEditorTableRows({
|
||||||
|
editor: editor,
|
||||||
|
tableFormulas: options.tableFormulas || [],
|
||||||
|
onChange: options.onChange
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach to the form
|
||||||
|
tableRows.attachToForm(frm);
|
||||||
|
|
||||||
|
// Store reference on the form
|
||||||
|
frm._tableFormulasEditor = tableRows;
|
||||||
|
|
||||||
|
return tableRows;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize triggers for a form
|
||||||
|
* @param {frappe.ui.form.Form} frm - Form instance
|
||||||
|
* @param {Object} [options={}] - Additional options
|
||||||
|
* @returns {FormulaEditorTriggers} Triggers handler instance
|
||||||
|
*/
|
||||||
|
initTriggers(frm, options = {}) {
|
||||||
|
if (!frm || !frm.doctype) {
|
||||||
|
console.error('Invalid form provided to initTriggers');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formId = `triggers_${frm.doctype}`;
|
||||||
|
|
||||||
|
// Check if already initialized
|
||||||
|
if (frm._triggersEditor) return frm._triggersEditor;
|
||||||
|
|
||||||
|
// Create a minimal editor instance for the form
|
||||||
|
const editorOptions = {
|
||||||
|
id: formId,
|
||||||
|
doctype: frm.doctype,
|
||||||
|
doc: frm.doc,
|
||||||
|
is_trigger: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = new FormulaEditor(editorOptions);
|
||||||
|
|
||||||
|
// Create triggers handler
|
||||||
|
const triggers = new FormulaEditorTriggers({
|
||||||
|
editor: editor,
|
||||||
|
triggers: options.triggers || [],
|
||||||
|
onChange: options.onChange
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach to the form
|
||||||
|
triggers.attachToForm(frm);
|
||||||
|
|
||||||
|
// Store reference on the form
|
||||||
|
frm._triggersEditor = triggers;
|
||||||
|
|
||||||
|
return triggers;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a field into a formula editor
|
||||||
|
* @param {frappe.ui.form.Form} frm - Form instance
|
||||||
|
* @param {string} fieldname - Field to convert
|
||||||
|
* @param {Object} [options={}] - Additional options
|
||||||
|
*/
|
||||||
|
makeFieldFormula(frm, fieldname, options = {}) {
|
||||||
|
if (!frm || !fieldname) return;
|
||||||
|
|
||||||
|
const field = frm.get_field(fieldname);
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
// Add formula button to the field
|
||||||
|
if (!field.$wrapper.find('.formula-editor-btn').length) {
|
||||||
|
const $btn = $(`
|
||||||
|
<button class="btn btn-xs btn-default formula-editor-btn" title="${__('Edit Formula')}">
|
||||||
|
<i class="fa fa-calculator"></i>
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
|
||||||
|
field.$wrapper.find('.control-input-wrapper').append($btn);
|
||||||
|
|
||||||
|
// Show formula editor on click
|
||||||
|
$btn.on('click', () => {
|
||||||
|
this.showFieldFormulaDialog(frm, fieldname, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show formula editor dialog for a field
|
||||||
|
* @param {frappe.ui.form.Form} frm - Form instance
|
||||||
|
* @param {string} fieldname - Field name
|
||||||
|
* @param {Object} [options={}] - Additional options
|
||||||
|
*/
|
||||||
|
showFieldFormulaDialog(frm, fieldname, options = {}) {
|
||||||
|
const field = frm.get_field(fieldname);
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
// Create dialog
|
||||||
|
const dialog = new frappe.ui.Dialog({
|
||||||
|
title: __('Formula Editor: {0}', [field.df.label || fieldname]),
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldtype: 'Code',
|
||||||
|
fieldname: 'formula',
|
||||||
|
label: __('Formula'),
|
||||||
|
options: 'JavaScript',
|
||||||
|
default: field.value || ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
primary_action_label: __('Apply'),
|
||||||
|
primary_action: (values) => {
|
||||||
|
field.set_input(values.formula);
|
||||||
|
dialog.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register setup hooks
|
||||||
|
$(document).on('form-load', function(event, frm) {
|
||||||
|
// Find formula fields and convert them to formula editors
|
||||||
|
frm.meta.fields.forEach(df => {
|
||||||
|
if (df.fieldtype === 'Code' && df.options === 'Formula') {
|
||||||
|
frappe.formula_editor.makeFieldFormula(frm, df.fieldname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default frappe.formula_editor;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,851 @@
|
||||||
|
/**
|
||||||
|
* Formula Editor Table Row Formulas
|
||||||
|
* Handles formulas for table rows in documents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
safeEval,
|
||||||
|
validateFormula,
|
||||||
|
extractReferencedFields,
|
||||||
|
formatErrorMessage,
|
||||||
|
debounce,
|
||||||
|
isEmpty,
|
||||||
|
deepClone,
|
||||||
|
showToast
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
class FormulaEditorTableRows {
|
||||||
|
/**
|
||||||
|
* Create a new FormulaEditorTableRows instance
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {Object} options.editor - Reference to the main FormulaEditor instance
|
||||||
|
* @param {Array} [options.tableFormulas=[]] - Initial table formulas
|
||||||
|
* @param {Function} [options.onChange] - Callback for formula changes
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.options = Object.assign({
|
||||||
|
editor: null,
|
||||||
|
tableFormulas: [],
|
||||||
|
onChange: null
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
// Validate required options
|
||||||
|
if (!this.options.editor) {
|
||||||
|
throw new Error(__('Editor instance is required for FormulaEditorTableRows'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize properties
|
||||||
|
this.editor = this.options.editor;
|
||||||
|
this.tableFormulas = deepClone(this.options.tableFormulas || []);
|
||||||
|
this.childTableFields = [];
|
||||||
|
this.debouncedOnChange = debounce(this.handleChange.bind(this), 300);
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize table formulas
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Find all child table fields in the doctype
|
||||||
|
this.loadChildTableFields();
|
||||||
|
|
||||||
|
// Ensure all table formulas have proper structure
|
||||||
|
this.validateTableFormulas();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load child table fields from the doctype
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
loadChildTableFields() {
|
||||||
|
const allFields = this.editor.fields || [];
|
||||||
|
|
||||||
|
// Filter to only table type fields
|
||||||
|
this.childTableFields = allFields.filter(field =>
|
||||||
|
field.type === 'Table' && field.options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate table formulas structure and add missing properties
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
validateTableFormulas() {
|
||||||
|
if (!Array.isArray(this.tableFormulas)) {
|
||||||
|
this.tableFormulas = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each table formula to ensure it has all required properties
|
||||||
|
this.tableFormulas = this.tableFormulas.map(formula => {
|
||||||
|
// Skip if not an object
|
||||||
|
if (typeof formula !== 'object' || formula === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if table field exists
|
||||||
|
const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field);
|
||||||
|
if (!tableField) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure required properties
|
||||||
|
return {
|
||||||
|
id: formula.id || `${formula.table_field}_${formula.target_field}_formula`,
|
||||||
|
name: formula.name || __('Formula for {0}', [formula.target_field]),
|
||||||
|
table_field: formula.table_field,
|
||||||
|
target_field: formula.target_field,
|
||||||
|
source_fields: Array.isArray(formula.source_fields) ? formula.source_fields : [],
|
||||||
|
formula: formula.formula || '',
|
||||||
|
child_doctype: tableField.options,
|
||||||
|
enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
|
||||||
|
apply_on: formula.apply_on || 'change', // change, save, manual
|
||||||
|
is_cumulative: !!formula.is_cumulative,
|
||||||
|
description: formula.description || ''
|
||||||
|
};
|
||||||
|
}).filter(Boolean); // Remove invalid formulas
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all table formulas
|
||||||
|
* @returns {Array} List of table formulas
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getTableFormulas() {
|
||||||
|
return deepClone(this.tableFormulas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a table formula by ID
|
||||||
|
* @param {string} id - Formula ID
|
||||||
|
* @returns {Object|null} Formula object or null if not found
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getTableFormula(id) {
|
||||||
|
const formula = this.tableFormulas.find(f => f.id === id);
|
||||||
|
return formula ? deepClone(formula) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get table formulas for a specific table field
|
||||||
|
* @param {string} tableField - Table field name
|
||||||
|
* @returns {Array} List of formulas for the table field
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getFormulasForTable(tableField) {
|
||||||
|
return this.tableFormulas.filter(f => f.table_field === tableField).map(f => deepClone(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new table formula
|
||||||
|
* @param {Object} formula - Formula data
|
||||||
|
* @returns {string} New formula ID
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
addTableFormula(formula = {}) {
|
||||||
|
if (!formula.table_field || !formula.target_field) {
|
||||||
|
throw new Error(__('Table field and target field are required'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if table field exists
|
||||||
|
const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field);
|
||||||
|
if (!tableField) {
|
||||||
|
throw new Error(__('Table field {0} not found', [formula.table_field]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFormula = {
|
||||||
|
id: formula.id || `${formula.table_field}_${formula.target_field}_formula`,
|
||||||
|
name: formula.name || __('Formula for {0}', [formula.target_field]),
|
||||||
|
table_field: formula.table_field,
|
||||||
|
target_field: formula.target_field,
|
||||||
|
source_fields: Array.isArray(formula.source_fields) ? deepClone(formula.source_fields) : [],
|
||||||
|
formula: formula.formula || '',
|
||||||
|
child_doctype: tableField.options,
|
||||||
|
enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
|
||||||
|
apply_on: formula.apply_on || 'change',
|
||||||
|
is_cumulative: !!formula.is_cumulative,
|
||||||
|
description: formula.description || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tableFormulas.push(newFormula);
|
||||||
|
this.debouncedOnChange();
|
||||||
|
|
||||||
|
return newFormula.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a table formula
|
||||||
|
* @param {string} id - Formula ID
|
||||||
|
* @param {Object} updates - Properties to update
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
updateTableFormula(id, updates) {
|
||||||
|
const index = this.tableFormulas.findIndex(f => f.id === id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
|
||||||
|
// Clone the formula
|
||||||
|
const formula = deepClone(this.tableFormulas[index]);
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
// Handle special properties
|
||||||
|
if (key === 'source_fields' && Array.isArray(updates.source_fields)) {
|
||||||
|
formula.source_fields = deepClone(updates.source_fields);
|
||||||
|
} else if (key === 'table_field') {
|
||||||
|
// If table field is updated, verify it exists
|
||||||
|
const tableField = this.childTableFields.find(f => f.fieldname === updates.table_field);
|
||||||
|
if (tableField) {
|
||||||
|
formula.table_field = updates.table_field;
|
||||||
|
formula.child_doctype = tableField.options;
|
||||||
|
}
|
||||||
|
} else if (key !== 'id' && key !== 'child_doctype') { // Don't allow changing ID or child_doctype directly
|
||||||
|
formula[key] = updates[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the formula
|
||||||
|
this.tableFormulas[index] = formula;
|
||||||
|
this.debouncedOnChange();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a table formula
|
||||||
|
* @param {string} id - Formula ID
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
deleteTableFormula(id) {
|
||||||
|
const index = this.tableFormulas.findIndex(f => f.id === id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
|
||||||
|
this.tableFormulas.splice(index, 1);
|
||||||
|
this.debouncedOnChange();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable a table formula
|
||||||
|
* @param {string} id - Formula ID
|
||||||
|
* @param {boolean} enabled - Whether to enable the formula
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
setTableFormulaEnabled(id, enabled) {
|
||||||
|
return this.updateTableFormula(id, { enabled: !!enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a table formula
|
||||||
|
* @param {string} id - Formula ID
|
||||||
|
* @returns {Object} Validation result with isValid and error
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
validateTableFormula(id) {
|
||||||
|
const formula = this.getTableFormula(id);
|
||||||
|
if (!formula) {
|
||||||
|
return { isValid: false, error: __('Formula not found') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formula.formula.trim()) {
|
||||||
|
return { isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use basic formula validation
|
||||||
|
return validateFormula(formula.formula);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field options for a child table
|
||||||
|
* @param {string} childDoctype - Child doctype name
|
||||||
|
* @returns {Promise<Array>} List of field options
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async getChildTableFields(childDoctype) {
|
||||||
|
try {
|
||||||
|
let meta;
|
||||||
|
|
||||||
|
// Try to get meta from cache
|
||||||
|
meta = frappe.get_meta(childDoctype);
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
// If not in cache, fetch it
|
||||||
|
meta = await frappe.model.with_doctype(childDoctype);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meta || !meta.fields) {
|
||||||
|
throw new Error(__('Could not fetch metadata for {0}', [childDoctype]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = [];
|
||||||
|
|
||||||
|
// Add fields from the child DocType
|
||||||
|
for (const field of meta.fields) {
|
||||||
|
// Skip certain field types
|
||||||
|
if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
label: field.label || field.fieldname,
|
||||||
|
type: field.fieldtype,
|
||||||
|
options: field.options,
|
||||||
|
is_child_table: field.fieldtype === 'Table'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting fields for child doctype ${childDoctype}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply formulas to a table
|
||||||
|
* @param {Object} doc - Parent document
|
||||||
|
* @param {string} tableField - Table field name
|
||||||
|
* @param {Object} [options] - Options for applying formulas
|
||||||
|
* @param {boolean} [options.silent=false] - Whether to suppress notifications
|
||||||
|
* @returns {Promise<Object>} Result with success status and error if any
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async applyTableFormulas(doc, tableField, options = {}) {
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
if (!doc || !tableField || !doc[tableField] || !Array.isArray(doc[tableField])) {
|
||||||
|
return { success: false, error: __('Invalid table data') };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get formulas for this table
|
||||||
|
const formulas = this.getFormulasForTable(tableField);
|
||||||
|
|
||||||
|
if (formulas.length === 0) {
|
||||||
|
return { success: true, message: __('No formulas to apply') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply each formula to all rows
|
||||||
|
for (const formula of formulas) {
|
||||||
|
if (!formula.enabled) continue;
|
||||||
|
|
||||||
|
for (let rowIdx = 0; rowIdx < doc[tableField].length; rowIdx++) {
|
||||||
|
await this.applyFormulaToRow(formula, doc, rowIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
showToast(__('Applied {0} formulas to {1} rows', [formulas.length, doc[tableField].length]), 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error applying table formulas:', error);
|
||||||
|
if (!silent) {
|
||||||
|
showToast(__('Error applying table formulas: {0}', [formatErrorMessage(error)]), 'error');
|
||||||
|
}
|
||||||
|
return { success: false, error: formatErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a formula to a specific row
|
||||||
|
* @param {Object} formula - Formula configuration
|
||||||
|
* @param {Object} doc - Parent document
|
||||||
|
* @param {number} rowIdx - Row index
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async applyFormulaToRow(formula, doc, rowIdx) {
|
||||||
|
try {
|
||||||
|
if (!formula.enabled || !formula.formula.trim()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = doc[formula.table_field];
|
||||||
|
if (!table || !Array.isArray(table) || rowIdx >= table.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = table[rowIdx];
|
||||||
|
const previousRows = table.slice(0, rowIdx);
|
||||||
|
const allRows = table;
|
||||||
|
|
||||||
|
// Create evaluation context
|
||||||
|
const context = {
|
||||||
|
doc: doc,
|
||||||
|
row: row,
|
||||||
|
rowIdx: rowIdx,
|
||||||
|
previousRows: previousRows,
|
||||||
|
allRows: allRows,
|
||||||
|
frappe: frappe
|
||||||
|
};
|
||||||
|
|
||||||
|
// Evaluate the formula
|
||||||
|
const result = await this.evaluateRowFormula(formula.formula, context);
|
||||||
|
|
||||||
|
// Apply the result to the target field
|
||||||
|
if (result !== undefined) {
|
||||||
|
row[formula.target_field] = result;
|
||||||
|
|
||||||
|
// If this is a cumulative formula, propagate changes to subsequent rows
|
||||||
|
if (formula.is_cumulative) {
|
||||||
|
await this.propagateCumulativeChanges(formula, doc, rowIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error applying formula to row ${rowIdx}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a formula in the context of a row
|
||||||
|
* @param {string} formula - Formula to evaluate
|
||||||
|
* @param {Object} context - Evaluation context
|
||||||
|
* @returns {Promise<*>} Evaluation result
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async evaluateRowFormula(formula, context) {
|
||||||
|
return safeEval(formula, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagate cumulative changes to subsequent rows
|
||||||
|
* @param {Object} formula - Formula configuration
|
||||||
|
* @param {Object} doc - Parent document
|
||||||
|
* @param {number} startRowIdx - Starting row index
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async propagateCumulativeChanges(formula, doc, startRowIdx) {
|
||||||
|
const table = doc[formula.table_field];
|
||||||
|
if (!table || !Array.isArray(table) || startRowIdx >= table.length - 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply formula to all subsequent rows
|
||||||
|
for (let rowIdx = startRowIdx + 1; rowIdx < table.length; rowIdx++) {
|
||||||
|
await this.applyFormulaToRow(formula, doc, rowIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up event listeners for a form
|
||||||
|
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
attachToForm(form) {
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// Store the current form
|
||||||
|
this.currentForm = form;
|
||||||
|
|
||||||
|
// Set up table field change listeners
|
||||||
|
this.childTableFields.forEach(tableField => {
|
||||||
|
const fieldName = tableField.fieldname;
|
||||||
|
|
||||||
|
// Get the grid for this table
|
||||||
|
const grid = form.fields_dict[fieldName]?.grid;
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
// Save original grid row functions to extend them
|
||||||
|
if (!grid._original_refresh) {
|
||||||
|
grid._original_refresh = grid.refresh;
|
||||||
|
grid.refresh = () => {
|
||||||
|
grid._original_refresh();
|
||||||
|
this.onGridRefresh(grid, fieldName, form);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grid._original_add_new_row) {
|
||||||
|
grid._original_add_new_row = grid.add_new_row;
|
||||||
|
grid.add_new_row = (idx, callback, from_bottom) => {
|
||||||
|
grid._original_add_new_row(idx, (row) => {
|
||||||
|
if (callback) callback(row);
|
||||||
|
this.onRowAdded(grid, row, fieldName, form);
|
||||||
|
}, from_bottom);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grid._original_setup_columns) {
|
||||||
|
grid._original_setup_columns = grid.setup_columns;
|
||||||
|
grid.setup_columns = () => {
|
||||||
|
grid._original_setup_columns();
|
||||||
|
this.setupGridColumns(grid, fieldName, form);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up form save listener
|
||||||
|
if (!form._tableFormulas_original_save) {
|
||||||
|
form._tableFormulas_original_save = form.save;
|
||||||
|
form.save = (save_action, callback, btn, on_error) => {
|
||||||
|
// Apply 'save' formulas
|
||||||
|
this.applyAllFormulasOnSave(form).then(() => {
|
||||||
|
// Call original save method
|
||||||
|
form._tableFormulas_original_save(save_action, callback, btn, on_error);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error applying table formulas on save:', error);
|
||||||
|
// Still call original save
|
||||||
|
form._tableFormulas_original_save(save_action, callback, btn, on_error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove event listeners from a form
|
||||||
|
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
detachFromForm(form) {
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// Restore original grid functions
|
||||||
|
this.childTableFields.forEach(tableField => {
|
||||||
|
const fieldName = tableField.fieldname;
|
||||||
|
const grid = form.fields_dict[fieldName]?.grid;
|
||||||
|
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
if (grid._original_refresh) {
|
||||||
|
grid.refresh = grid._original_refresh;
|
||||||
|
delete grid._original_refresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid._original_add_new_row) {
|
||||||
|
grid.add_new_row = grid._original_add_new_row;
|
||||||
|
delete grid._original_add_new_row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid._original_setup_columns) {
|
||||||
|
grid.setup_columns = grid._original_setup_columns;
|
||||||
|
delete grid._original_setup_columns;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore original save method
|
||||||
|
if (form._tableFormulas_original_save) {
|
||||||
|
form.save = form._tableFormulas_original_save;
|
||||||
|
delete form._tableFormulas_original_save;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current form reference
|
||||||
|
this.currentForm = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle grid refresh event
|
||||||
|
* @param {frappe.ui.form.Grid} grid - Grid instance
|
||||||
|
* @param {string} fieldName - Table field name
|
||||||
|
* @param {frappe.ui.form.Form} form - Form instance
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
onGridRefresh(grid, fieldName, form) {
|
||||||
|
// Set up row event handlers
|
||||||
|
grid.grid_rows.forEach(row => {
|
||||||
|
this.setupRowEvents(row, fieldName, form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle row added event
|
||||||
|
* @param {frappe.ui.form.Grid} grid - Grid instance
|
||||||
|
* @param {frappe.ui.form.GridRow} row - Grid row instance
|
||||||
|
* @param {string} fieldName - Table field name
|
||||||
|
* @param {frappe.ui.form.Form} form - Form instance
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
onRowAdded(grid, row, fieldName, form) {
|
||||||
|
// Set up events for the new row
|
||||||
|
this.setupRowEvents(row, fieldName, form);
|
||||||
|
|
||||||
|
// Apply 'change' formulas to the new row
|
||||||
|
setTimeout(() => {
|
||||||
|
const formulas = this.getFormulasForTable(fieldName).filter(f =>
|
||||||
|
f.enabled && f.apply_on === 'change'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (formulas.length > 0) {
|
||||||
|
const rowIdx = row.doc.idx - 1;
|
||||||
|
|
||||||
|
// Apply each formula
|
||||||
|
formulas.forEach(formula => {
|
||||||
|
this.applyFormulaToRow(formula, form.doc, rowIdx)
|
||||||
|
.then(() => {
|
||||||
|
// Refresh the row to show updated values
|
||||||
|
row.refresh();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`Error applying formula to new row:`, error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up events for a grid row
|
||||||
|
* @param {frappe.ui.form.GridRow} row - Grid row instance
|
||||||
|
* @param {string} fieldName - Table field name
|
||||||
|
* @param {frappe.ui.form.Form} form - Form instance
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
setupRowEvents(row, fieldName, form) {
|
||||||
|
// Skip if already set up
|
||||||
|
if (row._formulaEventsSetup) return;
|
||||||
|
|
||||||
|
// Get formulas for this table
|
||||||
|
const formulas = this.getFormulasForTable(fieldName).filter(f =>
|
||||||
|
f.enabled && f.apply_on === 'change'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (formulas.length === 0) return;
|
||||||
|
|
||||||
|
// Set up field change handlers
|
||||||
|
const sourceFields = new Set();
|
||||||
|
formulas.forEach(formula => {
|
||||||
|
formula.source_fields.forEach(field => {
|
||||||
|
sourceFields.add(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also add fields referenced in the formula
|
||||||
|
const referencedFields = extractReferencedFields(formula.formula, []);
|
||||||
|
referencedFields.forEach(field => {
|
||||||
|
if (field.startsWith('row.')) {
|
||||||
|
const rowField = field.substring(4);
|
||||||
|
sourceFields.add(rowField);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// For each source field, set up a change handler
|
||||||
|
sourceFields.forEach(fieldName => {
|
||||||
|
const field = row.fields_dict[fieldName];
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
// If the field already has our wrapper, don't add another one
|
||||||
|
if (field._formulaChangeHandlerAttached) return;
|
||||||
|
|
||||||
|
// Get the control's change function
|
||||||
|
const controlProto = Object.getPrototypeOf(field);
|
||||||
|
if (!controlProto || !controlProto.set_input) return;
|
||||||
|
|
||||||
|
// Save original set_input function
|
||||||
|
field._original_set_input = field.set_input;
|
||||||
|
|
||||||
|
// Override set_input to catch changes
|
||||||
|
field.set_input = function() {
|
||||||
|
const oldValue = field.value;
|
||||||
|
|
||||||
|
// Call original function
|
||||||
|
field._original_set_input.apply(field, arguments);
|
||||||
|
|
||||||
|
const newValue = field.value;
|
||||||
|
|
||||||
|
// If value changed, trigger our handler
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
row._formula_on_field_change(fieldName, newValue, oldValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
field._formulaChangeHandlerAttached = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add custom field change handler to the row
|
||||||
|
row._formula_on_field_change = debounce((changedField, newValue, oldValue) => {
|
||||||
|
const rowIdx = row.doc.idx - 1;
|
||||||
|
|
||||||
|
// Find formulas that depend on this field
|
||||||
|
const relevantFormulas = formulas.filter(formula =>
|
||||||
|
formula.source_fields.includes(changedField) ||
|
||||||
|
formula.formula.includes(`row.${changedField}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (relevantFormulas.length === 0) return;
|
||||||
|
|
||||||
|
// Apply each relevant formula
|
||||||
|
relevantFormulas.forEach(formula => {
|
||||||
|
this.applyFormulaToRow(formula, form.doc, rowIdx)
|
||||||
|
.then(() => {
|
||||||
|
// Refresh the row to show updated values
|
||||||
|
row.refresh();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`Error applying formula after field change:`, error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
row._formulaEventsSetup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up grid columns for better formula UX
|
||||||
|
* @param {frappe.ui.form.Grid} grid - Grid instance
|
||||||
|
* @param {string} fieldName - Table field name
|
||||||
|
* @param {frappe.ui.form.Form} form - Form instance
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
setupGridColumns(grid, fieldName, form) {
|
||||||
|
// Get formulas for this table
|
||||||
|
const formulas = this.getFormulasForTable(fieldName);
|
||||||
|
|
||||||
|
// Find columns that are calculated by formulas
|
||||||
|
formulas.forEach(formula => {
|
||||||
|
if (!formula.enabled) return;
|
||||||
|
|
||||||
|
const colField = grid.grid_columns_by_fieldname[formula.target_field];
|
||||||
|
if (!colField) return;
|
||||||
|
|
||||||
|
// Add a class to indicate it's calculated
|
||||||
|
const headerElement = colField.header_field;
|
||||||
|
if (headerElement && headerElement.classList) {
|
||||||
|
headerElement.classList.add('formula-calculated-field');
|
||||||
|
|
||||||
|
// Add tooltip
|
||||||
|
headerElement.title = __('This field is calculated by a formula');
|
||||||
|
|
||||||
|
// Add formula info if grid has a datatable
|
||||||
|
if (grid.datatable) {
|
||||||
|
const colIdx = grid.datatable.getColumnIndex(formula.target_field);
|
||||||
|
if (colIdx !== -1) {
|
||||||
|
const col = grid.datatable.datamanager.columns[colIdx];
|
||||||
|
if (col) {
|
||||||
|
col.formula = formula.formula;
|
||||||
|
col.isFormula = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all formulas that should run on save
|
||||||
|
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async applyAllFormulasOnSave(form) {
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// Process each table field
|
||||||
|
for (const tableField of this.childTableFields) {
|
||||||
|
const fieldName = tableField.fieldname;
|
||||||
|
|
||||||
|
// Get 'save' formulas for this table
|
||||||
|
const formulas = this.getFormulasForTable(fieldName).filter(f =>
|
||||||
|
f.enabled && f.apply_on === 'save'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (formulas.length > 0) {
|
||||||
|
// Apply formulas to the table
|
||||||
|
await this.applyTableFormulas(form.doc, fieldName, { silent: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle formula changes
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleChange() {
|
||||||
|
if (typeof this.options.onChange === 'function') {
|
||||||
|
this.options.onChange(this.tableFormulas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import table formulas from JSON
|
||||||
|
* @param {string|Object} json - JSON string or object
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
importTableFormulas(json) {
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (typeof json === 'string') {
|
||||||
|
data = JSON.parse(json);
|
||||||
|
} else if (typeof json === 'object' && json !== null) {
|
||||||
|
data = json;
|
||||||
|
} else {
|
||||||
|
throw new Error(__('Invalid import data format'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error(__('Import data must be an array'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each formula
|
||||||
|
const validFormulas = data.filter(formula =>
|
||||||
|
typeof formula === 'object' &&
|
||||||
|
formula !== null &&
|
||||||
|
typeof formula.table_field === 'string' &&
|
||||||
|
typeof formula.target_field === 'string'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validFormulas.length === 0) {
|
||||||
|
throw new Error(__('No valid table formulas found in import data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace table formulas
|
||||||
|
this.tableFormulas = validFormulas.map(formula => {
|
||||||
|
// Find table field in our list
|
||||||
|
const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: formula.id || `${formula.table_field}_${formula.target_field}_formula`,
|
||||||
|
name: formula.name || __('Formula for {0}', [formula.target_field]),
|
||||||
|
table_field: formula.table_field,
|
||||||
|
target_field: formula.target_field,
|
||||||
|
source_fields: Array.isArray(formula.source_fields) ? formula.source_fields : [],
|
||||||
|
formula: formula.formula || '',
|
||||||
|
child_doctype: tableField ? tableField.options : formula.child_doctype,
|
||||||
|
enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
|
||||||
|
apply_on: formula.apply_on || 'change',
|
||||||
|
is_cumulative: !!formula.is_cumulative,
|
||||||
|
description: formula.description || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.debouncedOnChange();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing table formulas:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export table formulas to JSON
|
||||||
|
* @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON
|
||||||
|
* @returns {string} JSON string
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exportTableFormulas(prettyPrint = false) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(this.tableFormulas, null, prettyPrint ? 2 : 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting table formulas:', error);
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormulaEditorTableRows;
|
||||||
|
|
@ -0,0 +1,803 @@
|
||||||
|
/**
|
||||||
|
* Formula Editor Triggers
|
||||||
|
* Handles trigger functionality for formula evaluation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateUniqueId,
|
||||||
|
validateFormula,
|
||||||
|
formatErrorMessage,
|
||||||
|
debounce,
|
||||||
|
safeEval,
|
||||||
|
isEmpty,
|
||||||
|
deepClone,
|
||||||
|
showToast
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
class FormulaEditorTriggers {
|
||||||
|
/**
|
||||||
|
* Create a new FormulaEditorTriggers instance
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {Object} options.editor - Reference to the main FormulaEditor instance
|
||||||
|
* @param {Array} [options.triggers=[]] - Initial triggers
|
||||||
|
* @param {Function} [options.onChange] - Callback for trigger changes
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.options = Object.assign({
|
||||||
|
editor: null,
|
||||||
|
triggers: [],
|
||||||
|
onChange: null
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
// Validate required options
|
||||||
|
if (!this.options.editor) {
|
||||||
|
throw new Error(__('Editor instance is required for FormulaEditorTriggers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize properties
|
||||||
|
this.editor = this.options.editor;
|
||||||
|
this.triggers = deepClone(this.options.triggers || []);
|
||||||
|
this.activeTriggerId = null;
|
||||||
|
this.triggerFieldsCache = null;
|
||||||
|
this.debouncedOnChange = debounce(this.handleChange.bind(this), 300);
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize triggers
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Ensure all triggers have proper structure
|
||||||
|
this.validateTriggers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate triggers structure and add missing properties
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
validateTriggers() {
|
||||||
|
if (!Array.isArray(this.triggers)) {
|
||||||
|
this.triggers = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each trigger to ensure it has all required properties
|
||||||
|
this.triggers = this.triggers.map(trigger => {
|
||||||
|
// Skip if not an object
|
||||||
|
if (typeof trigger !== 'object' || trigger === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure required properties
|
||||||
|
return {
|
||||||
|
id: trigger.id || generateUniqueId('trigger'),
|
||||||
|
name: trigger.name || __('Unnamed Trigger'),
|
||||||
|
description: trigger.description || '',
|
||||||
|
condition: trigger.condition || '',
|
||||||
|
action: trigger.action || '',
|
||||||
|
fields: Array.isArray(trigger.fields) ? trigger.fields : [],
|
||||||
|
enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true,
|
||||||
|
source_doctype: trigger.source_doctype || this.editor.options.doctype,
|
||||||
|
target_doctype: trigger.target_doctype || this.editor.options.doctype,
|
||||||
|
event: trigger.event || 'on_change' // on_change, on_save, on_submit, etc.
|
||||||
|
};
|
||||||
|
}).filter(Boolean); // Remove invalid triggers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all triggers
|
||||||
|
* @returns {Array} List of triggers
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getTriggers() {
|
||||||
|
return deepClone(this.triggers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a trigger by ID
|
||||||
|
* @param {string} id - Trigger ID
|
||||||
|
* @returns {Object|null} Trigger object or null if not found
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getTrigger(id) {
|
||||||
|
const trigger = this.triggers.find(t => t.id === id);
|
||||||
|
return trigger ? deepClone(trigger) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active trigger
|
||||||
|
* @returns {Object|null} Active trigger object or null if none active
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getActiveTrigger() {
|
||||||
|
if (!this.activeTriggerId) return null;
|
||||||
|
return this.getTrigger(this.activeTriggerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set active trigger
|
||||||
|
* @param {string} id - Trigger ID to set as active
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
setActiveTrigger(id) {
|
||||||
|
const trigger = this.getTrigger(id);
|
||||||
|
if (!trigger) return false;
|
||||||
|
|
||||||
|
this.activeTriggerId = id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new trigger
|
||||||
|
* @param {Object} trigger - Trigger data
|
||||||
|
* @returns {string} New trigger ID
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
addTrigger(trigger = {}) {
|
||||||
|
const newTrigger = {
|
||||||
|
id: generateUniqueId('trigger'),
|
||||||
|
name: trigger.name || __('New Trigger'),
|
||||||
|
description: trigger.description || '',
|
||||||
|
condition: trigger.condition || '',
|
||||||
|
action: trigger.action || '',
|
||||||
|
fields: Array.isArray(trigger.fields) ? deepClone(trigger.fields) : [],
|
||||||
|
enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true,
|
||||||
|
source_doctype: trigger.source_doctype || this.editor.options.doctype,
|
||||||
|
target_doctype: trigger.target_doctype || this.editor.options.doctype,
|
||||||
|
event: trigger.event || 'on_change'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.triggers.push(newTrigger);
|
||||||
|
this.activeTriggerId = newTrigger.id;
|
||||||
|
this.debouncedOnChange();
|
||||||
|
|
||||||
|
return newTrigger.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a trigger
|
||||||
|
* @param {string} id - Trigger ID
|
||||||
|
* @param {Object} updates - Properties to update
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
updateTrigger(id, updates) {
|
||||||
|
const index = this.triggers.findIndex(t => t.id === id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
|
||||||
|
// Clone the trigger
|
||||||
|
const trigger = deepClone(this.triggers[index]);
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
// Handle special properties
|
||||||
|
if (key === 'fields' && Array.isArray(updates.fields)) {
|
||||||
|
trigger.fields = deepClone(updates.fields);
|
||||||
|
} else if (key !== 'id') { // Don't allow changing the ID
|
||||||
|
trigger[key] = updates[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the trigger
|
||||||
|
this.triggers[index] = trigger;
|
||||||
|
this.debouncedOnChange();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a trigger
|
||||||
|
* @param {string} id - Trigger ID
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
deleteTrigger(id) {
|
||||||
|
const index = this.triggers.findIndex(t => t.id === id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
|
||||||
|
this.triggers.splice(index, 1);
|
||||||
|
|
||||||
|
// Clear active trigger if it was deleted
|
||||||
|
if (this.activeTriggerId === id) {
|
||||||
|
this.activeTriggerId = this.triggers.length > 0 ? this.triggers[0].id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debouncedOnChange();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable a trigger
|
||||||
|
* @param {string} id - Trigger ID
|
||||||
|
* @param {boolean} enabled - Whether to enable the trigger
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
setTriggerEnabled(id, enabled) {
|
||||||
|
return this.updateTrigger(id, { enabled: !!enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder triggers
|
||||||
|
* @param {Array} newOrder - Array of trigger IDs in the new order
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
reorderTriggers(newOrder) {
|
||||||
|
if (!Array.isArray(newOrder) || newOrder.length !== this.triggers.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all IDs exist
|
||||||
|
const validIds = new Set(this.triggers.map(t => t.id));
|
||||||
|
const allIdsExist = newOrder.every(id => validIds.has(id));
|
||||||
|
|
||||||
|
if (!allIdsExist) return false;
|
||||||
|
|
||||||
|
// Create new ordered array
|
||||||
|
const ordered = [];
|
||||||
|
for (const id of newOrder) {
|
||||||
|
const trigger = this.triggers.find(t => t.id === id);
|
||||||
|
if (trigger) ordered.push(deepClone(trigger));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.triggers = ordered;
|
||||||
|
this.debouncedOnChange();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a trigger condition or action
|
||||||
|
* @param {string} formula - Formula to validate
|
||||||
|
* @param {string} type - 'condition' or 'action'
|
||||||
|
* @returns {Object} Validation result with isValid and error
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
validateTriggerFormula(formula, type = 'condition') {
|
||||||
|
if (!formula.trim()) {
|
||||||
|
return { isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use basic formula validation
|
||||||
|
return validateFormula(formula);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available fields for a trigger
|
||||||
|
* @param {string} doctype - DocType to get fields for
|
||||||
|
* @returns {Array} List of available fields
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async getTriggerFields(doctype) {
|
||||||
|
// Use cache if available
|
||||||
|
if (this.triggerFieldsCache && this.triggerFieldsCache[doctype]) {
|
||||||
|
return deepClone(this.triggerFieldsCache[doctype]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fields = [];
|
||||||
|
let meta;
|
||||||
|
|
||||||
|
// Try to get meta from cache
|
||||||
|
meta = frappe.get_meta(doctype);
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
// If not in cache, fetch it
|
||||||
|
meta = await frappe.model.with_doctype(doctype);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meta || !meta.fields) {
|
||||||
|
throw new Error(__('Could not fetch metadata for {0}', [doctype]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add fields from the DocType
|
||||||
|
for (const field of meta.fields) {
|
||||||
|
// Skip certain field types
|
||||||
|
if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
label: field.label || field.fieldname,
|
||||||
|
type: field.fieldtype,
|
||||||
|
options: field.options,
|
||||||
|
is_child_table: field.fieldtype === 'Table'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the fields
|
||||||
|
if (!this.triggerFieldsCache) this.triggerFieldsCache = {};
|
||||||
|
this.triggerFieldsCache[doctype] = deepClone(fields);
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting trigger fields:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a trigger condition
|
||||||
|
* @param {string} id - Trigger ID
|
||||||
|
* @param {Object} [context] - Evaluation context
|
||||||
|
* @returns {Object} Test result with success, result, error
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
testTriggerCondition(id, context = {}) {
|
||||||
|
const trigger = this.getTrigger(id);
|
||||||
|
if (!trigger) {
|
||||||
|
return { success: false, result: false, error: __('Trigger not found') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trigger.condition.trim()) {
|
||||||
|
return { success: true, result: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create evaluation context
|
||||||
|
const evalContext = {
|
||||||
|
doc: this.editor.options.doc || {},
|
||||||
|
frappe: frappe,
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
|
||||||
|
// Evaluate the condition
|
||||||
|
const result = safeEval(trigger.condition, evalContext);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: !!result, // Convert to boolean
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing trigger condition:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
result: false,
|
||||||
|
error: formatErrorMessage(error)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a trigger action
|
||||||
|
* @param {string} id - Trigger ID
|
||||||
|
* @param {Object} [context] - Evaluation context
|
||||||
|
* @returns {Object} Test result with success, result, error
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
testTriggerAction(id, context = {}) {
|
||||||
|
const trigger = this.getTrigger(id);
|
||||||
|
if (!trigger) {
|
||||||
|
return { success: false, result: null, error: __('Trigger not found') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trigger.action.trim()) {
|
||||||
|
return { success: true, result: null, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create evaluation context
|
||||||
|
const evalContext = {
|
||||||
|
doc: this.editor.options.doc || {},
|
||||||
|
frappe: frappe,
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
|
||||||
|
// Evaluate the action
|
||||||
|
const result = safeEval(trigger.action, evalContext);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: result,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing trigger action:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
result: null,
|
||||||
|
error: formatErrorMessage(error)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a trigger
|
||||||
|
* @param {string} id - Trigger ID
|
||||||
|
* @param {Object} [context] - Evaluation context
|
||||||
|
* @returns {Object} Execution result with success, actionResult, error
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
executeTrigger(id, context = {}) {
|
||||||
|
const trigger = this.getTrigger(id);
|
||||||
|
if (!trigger) {
|
||||||
|
return { success: false, actionResult: null, error: __('Trigger not found') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip disabled triggers
|
||||||
|
if (!trigger.enabled) {
|
||||||
|
return { success: true, actionResult: null, error: __('Trigger is disabled') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check condition
|
||||||
|
const conditionResult = this.testTriggerCondition(id, context);
|
||||||
|
|
||||||
|
if (!conditionResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionResult: null,
|
||||||
|
error: __('Condition evaluation failed: {0}', [conditionResult.error])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if condition is not met
|
||||||
|
if (!conditionResult.result) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
actionResult: null,
|
||||||
|
error: __('Condition not met')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute action
|
||||||
|
const actionResult = this.testTriggerAction(id, context);
|
||||||
|
|
||||||
|
if (!actionResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionResult: null,
|
||||||
|
error: __('Action execution failed: {0}', [actionResult.error])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
actionResult: actionResult.result,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute all triggers for an event
|
||||||
|
* @param {string} event - Event name (e.g., 'on_change', 'on_save')
|
||||||
|
* @param {Object} [context] - Evaluation context
|
||||||
|
* @returns {Array} Execution results for each trigger
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
executeTriggersForEvent(event, context = {}) {
|
||||||
|
// Filter triggers by event and doctype
|
||||||
|
const triggers = this.triggers.filter(trigger =>
|
||||||
|
trigger.enabled &&
|
||||||
|
trigger.event === event &&
|
||||||
|
trigger.source_doctype === context.doctype
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Execute each trigger
|
||||||
|
for (const trigger of triggers) {
|
||||||
|
const result = this.executeTrigger(trigger.id, context);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
triggerId: trigger.id,
|
||||||
|
triggerName: trigger.name,
|
||||||
|
...result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up event listeners for a form
|
||||||
|
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
attachToForm(form) {
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// Store the current form
|
||||||
|
this.currentForm = form;
|
||||||
|
|
||||||
|
// Set up field change listeners
|
||||||
|
form.fields.forEach(fieldObj => {
|
||||||
|
const field = fieldObj.df;
|
||||||
|
|
||||||
|
// Skip certain field types
|
||||||
|
if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up change listener
|
||||||
|
const fieldName = field.fieldname;
|
||||||
|
if (fieldName) {
|
||||||
|
const control = form.get_field(fieldName);
|
||||||
|
if (control && control.$input) {
|
||||||
|
// Create debounced handler for each field
|
||||||
|
const debouncedHandler = debounce(() => {
|
||||||
|
this.handleFieldChange(fieldName, form);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Save the original change event if exists
|
||||||
|
if (control.df._original_change) return;
|
||||||
|
control.df._original_change = control.df.change;
|
||||||
|
|
||||||
|
// Set the new change event
|
||||||
|
control.df.change = () => {
|
||||||
|
// Call original event if exists
|
||||||
|
if (typeof control.df._original_change === 'function') {
|
||||||
|
control.df._original_change();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the debounced handler
|
||||||
|
debouncedHandler();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up form save listener
|
||||||
|
if (!form._original_save) {
|
||||||
|
form._original_save = form.save;
|
||||||
|
form.save = (save_action, callback, btn, on_error) => {
|
||||||
|
// Execute 'on_save' triggers
|
||||||
|
this.executeTriggersForEvent('on_save', {
|
||||||
|
doc: form.doc,
|
||||||
|
doctype: form.doctype,
|
||||||
|
form: form
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call original save method
|
||||||
|
form._original_save(save_action, callback, btn, on_error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up form submit listener
|
||||||
|
if (!form._original_savesubmit) {
|
||||||
|
form._original_savesubmit = form.savesubmit;
|
||||||
|
form.savesubmit = (callback, btn, on_error) => {
|
||||||
|
// Execute 'on_submit' triggers
|
||||||
|
this.executeTriggersForEvent('on_submit', {
|
||||||
|
doc: form.doc,
|
||||||
|
doctype: form.doctype,
|
||||||
|
form: form
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call original savesubmit method
|
||||||
|
form._original_savesubmit(callback, btn, on_error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove event listeners from a form
|
||||||
|
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
detachFromForm(form) {
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// Restore original field change events
|
||||||
|
form.fields.forEach(fieldObj => {
|
||||||
|
const field = fieldObj.df;
|
||||||
|
const fieldName = field.fieldname;
|
||||||
|
|
||||||
|
if (fieldName) {
|
||||||
|
const control = form.get_field(fieldName);
|
||||||
|
if (control && control.df._original_change) {
|
||||||
|
control.df.change = control.df._original_change;
|
||||||
|
delete control.df._original_change;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore original save method
|
||||||
|
if (form._original_save) {
|
||||||
|
form.save = form._original_save;
|
||||||
|
delete form._original_save;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original savesubmit method
|
||||||
|
if (form._original_savesubmit) {
|
||||||
|
form.savesubmit = form._original_savesubmit;
|
||||||
|
delete form._original_savesubmit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current form reference
|
||||||
|
this.currentForm = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle field change event
|
||||||
|
* @param {string} fieldName - Name of the changed field
|
||||||
|
* @param {frappe.ui.form.Form} form - Frappe form instance
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleFieldChange(fieldName, form) {
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// Execute 'on_change' triggers with field context
|
||||||
|
const results = this.executeTriggersForEvent('on_change', {
|
||||||
|
doc: form.doc,
|
||||||
|
doctype: form.doctype,
|
||||||
|
form: form,
|
||||||
|
field: fieldName,
|
||||||
|
value: form.doc[fieldName]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh form if any trigger executed successfully
|
||||||
|
const shouldRefresh = results.some(result => result.success);
|
||||||
|
if (shouldRefresh) {
|
||||||
|
// Delay refresh to avoid conflicts
|
||||||
|
setTimeout(() => {
|
||||||
|
form.refresh();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle trigger changes
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleChange() {
|
||||||
|
if (typeof this.options.onChange === 'function') {
|
||||||
|
this.options.onChange(this.triggers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import triggers from JSON
|
||||||
|
* @param {string|Object} json - JSON string or object
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
importTriggers(json) {
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (typeof json === 'string') {
|
||||||
|
data = JSON.parse(json);
|
||||||
|
} else if (typeof json === 'object' && json !== null) {
|
||||||
|
data = json;
|
||||||
|
} else {
|
||||||
|
throw new Error(__('Invalid import data format'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error(__('Import data must be an array'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each trigger
|
||||||
|
const validTriggers = data.filter(trigger =>
|
||||||
|
typeof trigger === 'object' &&
|
||||||
|
trigger !== null &&
|
||||||
|
typeof trigger.name === 'string'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validTriggers.length === 0) {
|
||||||
|
throw new Error(__('No valid triggers found in import data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace triggers
|
||||||
|
this.triggers = validTriggers.map(trigger => ({
|
||||||
|
id: trigger.id || generateUniqueId('trigger'),
|
||||||
|
name: trigger.name,
|
||||||
|
description: trigger.description || '',
|
||||||
|
condition: trigger.condition || '',
|
||||||
|
action: trigger.action || '',
|
||||||
|
fields: Array.isArray(trigger.fields) ? trigger.fields : [],
|
||||||
|
enabled: typeof trigger.enabled === 'boolean' ? trigger.enabled : true,
|
||||||
|
source_doctype: trigger.source_doctype || this.editor.options.doctype,
|
||||||
|
target_doctype: trigger.target_doctype || this.editor.options.doctype,
|
||||||
|
event: trigger.event || 'on_change'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Set first trigger as active
|
||||||
|
if (this.triggers.length > 0) {
|
||||||
|
this.activeTriggerId = this.triggers[0].id;
|
||||||
|
} else {
|
||||||
|
this.activeTriggerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debouncedOnChange();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing triggers:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export triggers to JSON
|
||||||
|
* @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON
|
||||||
|
* @returns {string} JSON string
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exportTriggers(prettyPrint = false) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(this.triggers, null, prettyPrint ? 2 : 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting triggers:', error);
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default triggers for a form
|
||||||
|
* @param {string} doctype - DocType to create triggers for
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async createDefaultTriggers(doctype) {
|
||||||
|
try {
|
||||||
|
if (this.triggers.length > 0) {
|
||||||
|
const confirmed = await new Promise(resolve => {
|
||||||
|
frappe.confirm(
|
||||||
|
__('This will replace all existing triggers. Continue?'),
|
||||||
|
() => resolve(true),
|
||||||
|
() => resolve(false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fields
|
||||||
|
const fields = await this.getTriggerFields(doctype);
|
||||||
|
|
||||||
|
// Create default triggers for numeric fields
|
||||||
|
const numericFields = fields.filter(f =>
|
||||||
|
['Int', 'Float', 'Currency', 'Percent'].includes(f.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultTriggers = [];
|
||||||
|
|
||||||
|
for (const field of numericFields) {
|
||||||
|
// Skip certain fields like ID, name, etc.
|
||||||
|
if (['name', 'owner', 'creation', 'modified', 'modified_by', 'idx'].includes(field.fieldname)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultTriggers.push({
|
||||||
|
name: __('Validate {0}', [field.label || field.fieldname]),
|
||||||
|
description: __('Validate {0} is not negative', [field.label || field.fieldname]),
|
||||||
|
condition: `doc.${field.fieldname} < 0`,
|
||||||
|
action: `frappe.throw(__("${field.label || field.fieldname} cannot be negative"));`,
|
||||||
|
fields: [field.fieldname],
|
||||||
|
enabled: true,
|
||||||
|
source_doctype: doctype,
|
||||||
|
target_doctype: doctype,
|
||||||
|
event: 'on_change'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the default triggers
|
||||||
|
if (defaultTriggers.length > 0) {
|
||||||
|
this.importTriggers(defaultTriggers);
|
||||||
|
showToast(__('Created {0} default triggers', [defaultTriggers.length]), 'success');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
showToast(__('No suitable fields found for default triggers'), 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating default triggers:', error);
|
||||||
|
showToast(__('Error creating default triggers'), 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormulaEditorTriggers;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,716 @@
|
||||||
|
/**
|
||||||
|
* Formula Editor Universal Fields
|
||||||
|
* Handles universal field functionality for cross-doctype formulas
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateUniqueId,
|
||||||
|
isEmpty,
|
||||||
|
deepClone,
|
||||||
|
formatErrorMessage,
|
||||||
|
showToast,
|
||||||
|
debounce
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
class FormulaEditorUniversalFields {
|
||||||
|
/**
|
||||||
|
* Create a new FormulaEditorUniversalFields instance
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {Object} options.editor - Reference to the main FormulaEditor instance
|
||||||
|
* @param {Array} [options.linkedDoctypes=[]] - Initially linked doctypes
|
||||||
|
* @param {Function} [options.onChange] - Callback for field changes
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.options = Object.assign({
|
||||||
|
editor: null,
|
||||||
|
linkedDoctypes: [],
|
||||||
|
onChange: null
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
// Validate required options
|
||||||
|
if (!this.options.editor) {
|
||||||
|
throw new Error(__('Editor instance is required for FormulaEditorUniversalFields'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize properties
|
||||||
|
this.editor = this.options.editor;
|
||||||
|
this.linkedDoctypes = deepClone(this.options.linkedDoctypes || []);
|
||||||
|
this.universalFields = [];
|
||||||
|
this.fieldCache = {};
|
||||||
|
this.debouncedOnChange = debounce(this.handleChange.bind(this), 300);
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize universal fields
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
// Ensure linked doctypes have proper structure
|
||||||
|
this.validateLinkedDoctypes();
|
||||||
|
|
||||||
|
// Load fields for linked doctypes
|
||||||
|
await this.loadUniversalFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate linked doctypes structure
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
validateLinkedDoctypes() {
|
||||||
|
if (!Array.isArray(this.linkedDoctypes)) {
|
||||||
|
this.linkedDoctypes = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each linked doctype to ensure it has all required properties
|
||||||
|
this.linkedDoctypes = this.linkedDoctypes.map(link => {
|
||||||
|
// Skip if not an object
|
||||||
|
if (typeof link !== 'object' || link === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure required properties
|
||||||
|
return {
|
||||||
|
id: link.id || generateUniqueId('link'),
|
||||||
|
label: link.label || link.doctype,
|
||||||
|
doctype: link.doctype,
|
||||||
|
linkField: link.linkField || null,
|
||||||
|
parentField: link.parentField || null,
|
||||||
|
fieldPrefix: link.fieldPrefix || link.doctype.toLowerCase().replace(/ /g, '_'),
|
||||||
|
fields: Array.isArray(link.fields) ? link.fields : []
|
||||||
|
};
|
||||||
|
}).filter(Boolean); // Remove invalid links
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load fields for all linked doctypes
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async loadUniversalFields() {
|
||||||
|
try {
|
||||||
|
this.universalFields = [];
|
||||||
|
|
||||||
|
// First load the main doctype fields
|
||||||
|
const mainDoctype = this.editor.options.doctype;
|
||||||
|
const mainFields = await this.getDocTypeFields(mainDoctype);
|
||||||
|
|
||||||
|
// Add main doctype fields with doctype prefix (for clarity in formulas)
|
||||||
|
mainFields.forEach(field => {
|
||||||
|
this.universalFields.push({
|
||||||
|
fieldname: `${mainDoctype.toLowerCase().replace(/ /g, '_')}.${field.fieldname}`,
|
||||||
|
original_fieldname: field.fieldname,
|
||||||
|
label: `${mainDoctype}: ${field.label || field.fieldname}`,
|
||||||
|
doctype: mainDoctype,
|
||||||
|
type: field.type,
|
||||||
|
options: field.options,
|
||||||
|
is_main_doctype: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load fields for each linked doctype
|
||||||
|
for (const link of this.linkedDoctypes) {
|
||||||
|
if (!link.doctype) continue;
|
||||||
|
|
||||||
|
let fields = [];
|
||||||
|
|
||||||
|
// If specific fields are defined, use those
|
||||||
|
if (Array.isArray(link.fields) && link.fields.length > 0) {
|
||||||
|
fields = link.fields.map(f => {
|
||||||
|
if (typeof f === 'string') {
|
||||||
|
return { fieldname: f, label: f };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Otherwise get all fields from the doctype
|
||||||
|
fields = await this.getDocTypeFields(link.doctype);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to universal fields with prefix
|
||||||
|
fields.forEach(field => {
|
||||||
|
this.universalFields.push({
|
||||||
|
fieldname: `${link.fieldPrefix}.${field.fieldname}`,
|
||||||
|
original_fieldname: field.fieldname,
|
||||||
|
label: `${link.label || link.doctype}: ${field.label || field.fieldname}`,
|
||||||
|
doctype: link.doctype,
|
||||||
|
type: field.type,
|
||||||
|
options: field.options,
|
||||||
|
linkInfo: {
|
||||||
|
linkField: link.linkField,
|
||||||
|
parentField: link.parentField,
|
||||||
|
fieldPrefix: link.fieldPrefix
|
||||||
|
},
|
||||||
|
is_main_doctype: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify about change
|
||||||
|
this.debouncedOnChange();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading universal fields:', error);
|
||||||
|
showToast(__('Error loading universal fields'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fields for a doctype
|
||||||
|
* @param {string} doctype - DocType name
|
||||||
|
* @returns {Promise<Array>} List of fields
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async getDocTypeFields(doctype) {
|
||||||
|
try {
|
||||||
|
// Check cache first
|
||||||
|
if (this.fieldCache[doctype]) {
|
||||||
|
return deepClone(this.fieldCache[doctype]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = [];
|
||||||
|
let meta;
|
||||||
|
|
||||||
|
// Try to get meta from cache
|
||||||
|
meta = frappe.get_meta(doctype);
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
// If not in cache, fetch it
|
||||||
|
meta = await frappe.model.with_doctype(doctype);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meta || !meta.fields) {
|
||||||
|
throw new Error(__('Could not fetch metadata for {0}', [doctype]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add fields from the DocType
|
||||||
|
for (const field of meta.fields) {
|
||||||
|
// Skip certain field types
|
||||||
|
if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
label: field.label || field.fieldname,
|
||||||
|
type: field.fieldtype,
|
||||||
|
options: field.options,
|
||||||
|
is_child_table: field.fieldtype === 'Table'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the fields
|
||||||
|
this.fieldCache[doctype] = deepClone(fields);
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting fields for doctype ${doctype}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all universal fields
|
||||||
|
* @returns {Array} List of universal fields
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getUniversalFields() {
|
||||||
|
return deepClone(this.universalFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get linked doctypes
|
||||||
|
* @returns {Array} List of linked doctypes
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getLinkedDoctypes() {
|
||||||
|
return deepClone(this.linkedDoctypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a linked doctype
|
||||||
|
* @param {Object} linkData - Linked doctype data
|
||||||
|
* @returns {string} New link ID
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async addLinkedDoctype(linkData = {}) {
|
||||||
|
if (!linkData.doctype) {
|
||||||
|
throw new Error(__('DocType is required for linked doctype'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLink = {
|
||||||
|
id: generateUniqueId('link'),
|
||||||
|
label: linkData.label || linkData.doctype,
|
||||||
|
doctype: linkData.doctype,
|
||||||
|
linkField: linkData.linkField || null,
|
||||||
|
parentField: linkData.parentField || null,
|
||||||
|
fieldPrefix: linkData.fieldPrefix || linkData.doctype.toLowerCase().replace(/ /g, '_'),
|
||||||
|
fields: Array.isArray(linkData.fields) ? deepClone(linkData.fields) : []
|
||||||
|
};
|
||||||
|
|
||||||
|
this.linkedDoctypes.push(newLink);
|
||||||
|
|
||||||
|
// Reload universal fields
|
||||||
|
await this.loadUniversalFields();
|
||||||
|
|
||||||
|
return newLink.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a linked doctype
|
||||||
|
* @param {string} id - Link ID
|
||||||
|
* @param {Object} updates - Properties to update
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async updateLinkedDoctype(id, updates) {
|
||||||
|
const index = this.linkedDoctypes.findIndex(l => l.id === id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
|
||||||
|
// Clone the link
|
||||||
|
const link = deepClone(this.linkedDoctypes[index]);
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
// Handle special properties
|
||||||
|
if (key === 'fields' && Array.isArray(updates.fields)) {
|
||||||
|
link.fields = deepClone(updates.fields);
|
||||||
|
} else if (key !== 'id') { // Don't allow changing the ID
|
||||||
|
link[key] = updates[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the link
|
||||||
|
this.linkedDoctypes[index] = link;
|
||||||
|
|
||||||
|
// Reload universal fields
|
||||||
|
await this.loadUniversalFields();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a linked doctype
|
||||||
|
* @param {string} id - Link ID
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async deleteLinkedDoctype(id) {
|
||||||
|
const index = this.linkedDoctypes.findIndex(l => l.id === id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
|
||||||
|
this.linkedDoctypes.splice(index, 1);
|
||||||
|
|
||||||
|
// Reload universal fields
|
||||||
|
await this.loadUniversalFields();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for doctypes to link
|
||||||
|
* @param {string} query - Search query
|
||||||
|
* @param {number} [limit=20] - Maximum number of results
|
||||||
|
* @returns {Promise<Array>} Search results
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async searchDoctypes(query, limit = 20) {
|
||||||
|
try {
|
||||||
|
const doctypes = await frappe.db.get_list('DocType', {
|
||||||
|
filters: {
|
||||||
|
name: ['like', `%${query}%`],
|
||||||
|
istable: 0,
|
||||||
|
issingle: 0
|
||||||
|
},
|
||||||
|
fields: ['name', 'module'],
|
||||||
|
limit: limit
|
||||||
|
});
|
||||||
|
|
||||||
|
return doctypes.map(dt => ({
|
||||||
|
value: dt.name,
|
||||||
|
label: dt.name,
|
||||||
|
description: dt.module
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching doctypes:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get link fields for a doctype
|
||||||
|
* @param {string} doctype - DocType name
|
||||||
|
* @returns {Promise<Array>} List of link fields
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async getLinkFields(doctype) {
|
||||||
|
try {
|
||||||
|
const fields = await this.getDocTypeFields(doctype);
|
||||||
|
|
||||||
|
// Filter to only link fields
|
||||||
|
return fields.filter(f =>
|
||||||
|
f.type === 'Link' ||
|
||||||
|
f.type === 'Dynamic Link'
|
||||||
|
).map(f => ({
|
||||||
|
value: f.fieldname,
|
||||||
|
label: f.label || f.fieldname,
|
||||||
|
fieldtype: f.type,
|
||||||
|
options: f.options
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting link fields for ${doctype}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find potential link fields between two doctypes
|
||||||
|
* @param {string} sourceDoctype - Source DocType name
|
||||||
|
* @param {string} targetDoctype - Target DocType name
|
||||||
|
* @returns {Promise<Array>} List of potential link fields
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async findPotentialLinks(sourceDoctype, targetDoctype) {
|
||||||
|
try {
|
||||||
|
const sourceFields = await this.getDocTypeFields(sourceDoctype);
|
||||||
|
const potentialLinks = [];
|
||||||
|
|
||||||
|
// Find Link fields that point to the target doctype
|
||||||
|
sourceFields.forEach(field => {
|
||||||
|
if (field.type === 'Link' && field.options === targetDoctype) {
|
||||||
|
potentialLinks.push({
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
label: field.label || field.fieldname,
|
||||||
|
linkType: 'direct',
|
||||||
|
description: __('Direct link to {0}', [targetDoctype])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Look for child tables that might link to the target
|
||||||
|
sourceFields.forEach(field => {
|
||||||
|
if (field.is_child_table) {
|
||||||
|
potentialLinks.push({
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
label: field.label || field.fieldname,
|
||||||
|
linkType: 'child_table',
|
||||||
|
description: __('Child table that might contain links to {0}', [targetDoctype]),
|
||||||
|
childDoctype: field.options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also check target doctype for links back to source
|
||||||
|
const targetFields = await this.getDocTypeFields(targetDoctype);
|
||||||
|
|
||||||
|
targetFields.forEach(field => {
|
||||||
|
if (field.type === 'Link' && field.options === sourceDoctype) {
|
||||||
|
potentialLinks.push({
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
label: field.label || field.fieldname,
|
||||||
|
linkType: 'reverse',
|
||||||
|
description: __('Reverse link from {0}', [targetDoctype])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return potentialLinks;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error finding potential links between ${sourceDoctype} and ${targetDoctype}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a formula to fetch data from a linked doctype
|
||||||
|
* @param {string} linkId - Link ID
|
||||||
|
* @param {string} fieldname - Field name to fetch
|
||||||
|
* @returns {string} Generated formula
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
generateLinkFormula(linkId, fieldname) {
|
||||||
|
const link = this.linkedDoctypes.find(l => l.id === linkId);
|
||||||
|
if (!link) return '';
|
||||||
|
|
||||||
|
const targetField = this.universalFields.find(f =>
|
||||||
|
f.doctype === link.doctype &&
|
||||||
|
f.original_fieldname === fieldname
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetField) return '';
|
||||||
|
|
||||||
|
// If it's a direct link (parent to child)
|
||||||
|
if (link.linkField) {
|
||||||
|
return `
|
||||||
|
// Get linked ${link.doctype} based on ${link.linkField}
|
||||||
|
(function() {
|
||||||
|
const linked = frappe.db.get_value('${link.doctype}', {
|
||||||
|
name: doc.${link.linkField}
|
||||||
|
}, '${fieldname}');
|
||||||
|
|
||||||
|
return linked ? linked.message.${fieldname} : null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a child table link
|
||||||
|
if (link.parentField) {
|
||||||
|
return `
|
||||||
|
// Get linked ${link.doctype} records where parent field matches
|
||||||
|
(function() {
|
||||||
|
const linked = frappe.db.get_list('${link.doctype}', {
|
||||||
|
filters: {
|
||||||
|
${link.parentField}: doc.name
|
||||||
|
},
|
||||||
|
fields: ['${fieldname}']
|
||||||
|
});
|
||||||
|
|
||||||
|
return linked ? linked.map(item => item.${fieldname}) : [];
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic case
|
||||||
|
return `
|
||||||
|
// Fetch data from ${link.doctype}
|
||||||
|
frappe.db.get_value('${link.doctype}', null, '${fieldname}')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a helper function to work with linked doctypes
|
||||||
|
* @param {string} functionType - Type of function to generate (get_linked, set_linked, etc.)
|
||||||
|
* @returns {string} Generated function code
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
generateHelperFunction(functionType) {
|
||||||
|
switch (functionType) {
|
||||||
|
case 'get_linked':
|
||||||
|
return `
|
||||||
|
/**
|
||||||
|
* Get a value from a linked document
|
||||||
|
* @param {string} doctype - DocType of linked document
|
||||||
|
* @param {Object} filters - Filters to find the document
|
||||||
|
* @param {string} field - Field to retrieve
|
||||||
|
* @returns {*} Field value
|
||||||
|
*/
|
||||||
|
function get_linked(doctype, filters, field) {
|
||||||
|
try {
|
||||||
|
const result = frappe.db.get_value(doctype, filters, field);
|
||||||
|
return result && result.message ? result.message[field] : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(\`Error getting linked value: \${error}\`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
case 'get_linked_list':
|
||||||
|
return `
|
||||||
|
/**
|
||||||
|
* Get values from linked documents
|
||||||
|
* @param {string} doctype - DocType of linked documents
|
||||||
|
* @param {Object} filters - Filters to find the documents
|
||||||
|
* @param {string|Array} fields - Fields to retrieve
|
||||||
|
* @returns {Array} List of documents/values
|
||||||
|
*/
|
||||||
|
function get_linked_list(doctype, filters, fields) {
|
||||||
|
try {
|
||||||
|
return frappe.db.get_list(doctype, {
|
||||||
|
filters: filters,
|
||||||
|
fields: fields
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(\`Error getting linked list: \${error}\`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
case 'set_linked':
|
||||||
|
return `
|
||||||
|
/**
|
||||||
|
* Update a value in a linked document
|
||||||
|
* @param {string} doctype - DocType of linked document
|
||||||
|
* @param {string} name - Name of linked document
|
||||||
|
* @param {Object} values - Values to update
|
||||||
|
* @returns {boolean} Success status
|
||||||
|
*/
|
||||||
|
function set_linked(doctype, name, values) {
|
||||||
|
try {
|
||||||
|
frappe.db.set_value(doctype, name, values);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(\`Error setting linked value: \${error}\`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a preview of data from linked doctypes
|
||||||
|
* @param {Object} doc - Current document
|
||||||
|
* @returns {Promise<Object>} Preview data
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async getLinkedDataPreview(doc) {
|
||||||
|
if (!doc) return {};
|
||||||
|
|
||||||
|
const preview = {};
|
||||||
|
|
||||||
|
for (const link of this.linkedDoctypes) {
|
||||||
|
try {
|
||||||
|
preview[link.fieldPrefix] = await this.getLinkedDocTypeData(link, doc);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting preview for ${link.doctype}:`, error);
|
||||||
|
preview[link.fieldPrefix] = { error: formatErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data for a linked doctype
|
||||||
|
* @param {Object} link - Link configuration
|
||||||
|
* @param {Object} doc - Current document
|
||||||
|
* @returns {Promise<Object|Array>} Linked data
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async getLinkedDocTypeData(link, doc) {
|
||||||
|
// If it's a direct link (parent to child)
|
||||||
|
if (link.linkField && doc[link.linkField]) {
|
||||||
|
// Get single linked document
|
||||||
|
const result = await frappe.db.get_value(
|
||||||
|
link.doctype,
|
||||||
|
{ name: doc[link.linkField] },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
|
||||||
|
return result && result.message ? result.message : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a child table link (child to parent)
|
||||||
|
if (link.parentField) {
|
||||||
|
// Get multiple linked documents
|
||||||
|
const result = await frappe.db.get_list(
|
||||||
|
link.doctype,
|
||||||
|
{
|
||||||
|
filters: {
|
||||||
|
[link.parentField]: doc.name
|
||||||
|
},
|
||||||
|
fields: ['*'],
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no specific link field is defined, return empty
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle field changes
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleChange() {
|
||||||
|
if (typeof this.options.onChange === 'function') {
|
||||||
|
this.options.onChange(this.universalFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update editor fields to include universal fields
|
||||||
|
if (this.editor && typeof this.editor.setFields === 'function') {
|
||||||
|
// Convert universal fields to editor field format
|
||||||
|
const editorFields = this.universalFields.map(field => ({
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
label: field.label,
|
||||||
|
type: field.type,
|
||||||
|
options: field.options,
|
||||||
|
universal: true,
|
||||||
|
doctype: field.doctype
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.editor.setFields(editorFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import linked doctypes configuration from JSON
|
||||||
|
* @param {string|Object} json - JSON string or object
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async importLinkedDoctypes(json) {
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (typeof json === 'string') {
|
||||||
|
data = JSON.parse(json);
|
||||||
|
} else if (typeof json === 'object' && json !== null) {
|
||||||
|
data = json;
|
||||||
|
} else {
|
||||||
|
throw new Error(__('Invalid import data format'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error(__('Import data must be an array'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each linked doctype
|
||||||
|
const validLinks = data.filter(link =>
|
||||||
|
typeof link === 'object' &&
|
||||||
|
link !== null &&
|
||||||
|
typeof link.doctype === 'string'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validLinks.length === 0) {
|
||||||
|
throw new Error(__('No valid linked doctypes found in import data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace linked doctypes
|
||||||
|
this.linkedDoctypes = validLinks.map(link => ({
|
||||||
|
id: link.id || generateUniqueId('link'),
|
||||||
|
label: link.label || link.doctype,
|
||||||
|
doctype: link.doctype,
|
||||||
|
linkField: link.linkField || null,
|
||||||
|
parentField: link.parentField || null,
|
||||||
|
fieldPrefix: link.fieldPrefix || link.doctype.toLowerCase().replace(/ /g, '_'),
|
||||||
|
fields: Array.isArray(link.fields) ? link.fields : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reload universal fields
|
||||||
|
await this.loadUniversalFields();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing linked doctypes:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export linked doctypes configuration to JSON
|
||||||
|
* @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON
|
||||||
|
* @returns {string} JSON string
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exportLinkedDoctypes(prettyPrint = false) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(this.linkedDoctypes, null, prettyPrint ? 2 : 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting linked doctypes:', error);
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormulaEditorUniversalFields;
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
/**
|
||||||
|
* Formula Editor Utilities
|
||||||
|
* Contains helper functions used across the Formula Editor modules
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { __ } from 'frappe.utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely evaluates a formula/expression
|
||||||
|
* @param {string} formula - The formula to evaluate
|
||||||
|
* @param {Object} context - Context variables for the formula
|
||||||
|
* @returns {*} The result of the evaluation
|
||||||
|
*/
|
||||||
|
function safeEval(formula, context = {}) {
|
||||||
|
try {
|
||||||
|
// Create a safe function with the provided context
|
||||||
|
const contextKeys = Object.keys(context);
|
||||||
|
const contextValues = Object.values(context);
|
||||||
|
|
||||||
|
// Using Function constructor with context variables as parameters
|
||||||
|
const evalFunction = new Function(...contextKeys, `"use strict"; return (${formula});`);
|
||||||
|
|
||||||
|
return evalFunction(...contextValues);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error evaluating formula: ${formula}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a formula is syntactically correct
|
||||||
|
* @param {string} formula - The formula to validate
|
||||||
|
* @returns {Object} Result with isValid and error message if any
|
||||||
|
*/
|
||||||
|
function validateFormula(formula) {
|
||||||
|
try {
|
||||||
|
// Simple validation by trying to parse as function
|
||||||
|
new Function(`"use strict"; return (${formula});`);
|
||||||
|
return { isValid: true, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: error.message.replace(/Function|new|constructor|[()]/g, '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes special characters in a string for use in regex
|
||||||
|
* @param {string} str - String to escape
|
||||||
|
* @returns {string} Escaped string
|
||||||
|
*/
|
||||||
|
function escapeRegExp(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all variables/fields referenced in a formula
|
||||||
|
* @param {string} formula - The formula to analyze
|
||||||
|
* @param {Array} availableFields - List of available fields
|
||||||
|
* @returns {Array} Array of field names found in the formula
|
||||||
|
*/
|
||||||
|
function extractReferencedFields(formula, availableFields) {
|
||||||
|
if (!formula || !availableFields || !availableFields.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedFields = [];
|
||||||
|
|
||||||
|
// Sort fields by length (descending) to match longest field names first
|
||||||
|
const sortedFields = [...availableFields].sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
|
for (const field of sortedFields) {
|
||||||
|
// Create a regex to find the field name (with word boundaries)
|
||||||
|
const fieldRegex = new RegExp(`\\b${escapeRegExp(field)}\\b`, 'g');
|
||||||
|
if (fieldRegex.test(formula)) {
|
||||||
|
referencedFields.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return referencedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a unique ID for DOM elements
|
||||||
|
* @param {string} prefix - Prefix for the ID
|
||||||
|
* @returns {string} Unique ID
|
||||||
|
*/
|
||||||
|
function generateUniqueId(prefix = 'fe') {
|
||||||
|
return `${prefix}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounces a function call
|
||||||
|
* @param {Function} func - Function to debounce
|
||||||
|
* @param {number} wait - Delay in milliseconds
|
||||||
|
* @returns {Function} Debounced function
|
||||||
|
*/
|
||||||
|
function debounce(func, wait = 300) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttles a function call
|
||||||
|
* @param {Function} func - Function to throttle
|
||||||
|
* @param {number} limit - Limit in milliseconds
|
||||||
|
* @returns {Function} Throttled function
|
||||||
|
*/
|
||||||
|
function throttle(func, limit = 300) {
|
||||||
|
let inThrottle;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func(...args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
inThrottle = false;
|
||||||
|
}, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats error messages for display
|
||||||
|
* @param {Error|string} error - Error object or message
|
||||||
|
* @returns {string} Formatted error message
|
||||||
|
*/
|
||||||
|
function formatErrorMessage(error) {
|
||||||
|
if (!error) return '';
|
||||||
|
|
||||||
|
const errorMsg = typeof error === 'string' ? error : error.message || __('Unknown Error');
|
||||||
|
return errorMsg.replace(/^Error:\s*/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a toast notification
|
||||||
|
* @param {string} message - Message to display
|
||||||
|
* @param {string} type - Type of notification (success, error, warning)
|
||||||
|
*/
|
||||||
|
function showToast(message, type = 'error') {
|
||||||
|
frappe.show_alert({
|
||||||
|
message: message,
|
||||||
|
indicator: type
|
||||||
|
}, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a field name to a human-readable label
|
||||||
|
* @param {string} fieldname - Field name to convert
|
||||||
|
* @returns {string} Human readable field label
|
||||||
|
*/
|
||||||
|
function fieldToLabel(fieldname) {
|
||||||
|
if (!fieldname) return '';
|
||||||
|
|
||||||
|
// Replace underscores and dots with spaces
|
||||||
|
let label = fieldname.replace(/[_\.]/g, ' ');
|
||||||
|
|
||||||
|
// Capitalize each word
|
||||||
|
label = label.replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a CSS class to an element if not already applied
|
||||||
|
* @param {HTMLElement} element - DOM element
|
||||||
|
* @param {string} className - CSS class to add
|
||||||
|
*/
|
||||||
|
function addClass(element, className) {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
if (!element.classList.contains(className)) {
|
||||||
|
element.classList.add(className);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a CSS class from an element
|
||||||
|
* @param {HTMLElement} element - DOM element
|
||||||
|
* @param {string} className - CSS class to remove
|
||||||
|
*/
|
||||||
|
function removeClass(element, className) {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
if (element.classList.contains(className)) {
|
||||||
|
element.classList.remove(className);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles a CSS class on an element
|
||||||
|
* @param {HTMLElement} element - DOM element
|
||||||
|
* @param {string} className - CSS class to toggle
|
||||||
|
* @param {boolean} condition - Optional condition to determine toggle
|
||||||
|
*/
|
||||||
|
function toggleClass(element, className, condition) {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
if (condition === undefined) {
|
||||||
|
element.classList.toggle(className);
|
||||||
|
} else {
|
||||||
|
condition ? addClass(element, className) : removeClass(element, className);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a string into a JSON object safely
|
||||||
|
* @param {string} str - String to parse
|
||||||
|
* @param {*} defaultValue - Default value if parsing fails
|
||||||
|
* @returns {*} Parsed JSON or default value
|
||||||
|
*/
|
||||||
|
function safeParseJSON(str, defaultValue = {}) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(str);
|
||||||
|
} catch (e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a variable is empty (null, undefined, empty string, empty array, empty object)
|
||||||
|
* @param {*} value - Value to check
|
||||||
|
* @returns {boolean} True if empty
|
||||||
|
*/
|
||||||
|
function isEmpty(value) {
|
||||||
|
if (value === null || value === undefined) return true;
|
||||||
|
if (typeof value === 'string' && value.trim() === '') return true;
|
||||||
|
if (Array.isArray(value) && value.length === 0) return true;
|
||||||
|
if (typeof value === 'object' && Object.keys(value).length === 0) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep clones an object
|
||||||
|
* @param {*} obj - Object to clone
|
||||||
|
* @returns {*} Cloned object
|
||||||
|
*/
|
||||||
|
function deepClone(obj) {
|
||||||
|
if (obj === null || typeof obj !== 'object') return obj;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback for circular references
|
||||||
|
const clone = Array.isArray(obj) ? [] : {};
|
||||||
|
|
||||||
|
Object.keys(obj).forEach(key => {
|
||||||
|
clone[key] = typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a value from an object using a path string (e.g., "customer.address.city")
|
||||||
|
* @param {Object} obj - Object to get value from
|
||||||
|
* @param {string} path - Path to the value
|
||||||
|
* @param {*} defaultValue - Default value if path doesn't exist
|
||||||
|
* @returns {*} Value at path or default value
|
||||||
|
*/
|
||||||
|
function getValueByPath(obj, path, defaultValue = undefined) {
|
||||||
|
if (!obj || !path) return defaultValue;
|
||||||
|
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = obj;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current === undefined || current === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current !== undefined ? current : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a key or formula is a valid JavaScript identifier
|
||||||
|
* @param {string} key - Key to validate
|
||||||
|
* @returns {boolean} True if valid
|
||||||
|
*/
|
||||||
|
function isValidIdentifier(key) {
|
||||||
|
if (!key || typeof key !== 'string') return false;
|
||||||
|
|
||||||
|
// JavaScript identifier regex (letters, numbers, underscore, dollar sign)
|
||||||
|
// Must start with letter, underscore or dollar sign
|
||||||
|
const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
||||||
|
|
||||||
|
return validIdentifierRegex.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
safeEval,
|
||||||
|
validateFormula,
|
||||||
|
escapeRegExp,
|
||||||
|
extractReferencedFields,
|
||||||
|
generateUniqueId,
|
||||||
|
debounce,
|
||||||
|
throttle,
|
||||||
|
formatErrorMessage,
|
||||||
|
showToast,
|
||||||
|
fieldToLabel,
|
||||||
|
addClass,
|
||||||
|
removeClass,
|
||||||
|
toggleClass,
|
||||||
|
safeParseJSON,
|
||||||
|
isEmpty,
|
||||||
|
deepClone,
|
||||||
|
getValueByPath,
|
||||||
|
isValidIdentifier
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue