From e934fe27824dfe34bf9451a775e2fbcbe3586739 Mon Sep 17 00:00:00 2001 From: "Abdallah A. Zaqout" <26047413+zaqoutabed@users.noreply.github.com> Date: Sun, 23 Nov 2025 14:09:58 +0300 Subject: [PATCH 001/401] fix: stop validation when click pervious btn --- frappe/public/js/frappe/web_form/web_form.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/web_form/web_form.js b/frappe/public/js/frappe/web_form/web_form.js index 5ee2c22128..71b167e065 100644 --- a/frappe/public/js/frappe/web_form/web_form.js +++ b/frappe/public/js/frappe/web_form/web_form.js @@ -103,9 +103,9 @@ export default class WebForm extends frappe.ui.FieldGroup { $(".web-form-footer .left-area").prepend(this.$previous_button); this.$previous_button.on("click", () => { - let is_validated = me.validate_section(); + // let is_validated = me.validate_section(); - if (!is_validated) return false; + // if (!is_validated) return false; /** The eslint utility cannot figure out if this is an infinite loop in backwards and From afdcdfb8518ff88dc94d7d363841e621c0e1407f Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:55:04 +0100 Subject: [PATCH 002/401] fix: input change handling in FieldGroup --- frappe/public/js/frappe/ui/field_group.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js index 760881770e..ed3368b1e1 100644 --- a/frappe/public/js/frappe/ui/field_group.js +++ b/frappe/public/js/frappe/ui/field_group.js @@ -48,13 +48,13 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout { $(this.wrapper) .find("input, select") - .on("change awesomplete-selectcomplete", () => { - this.dirty = true; - frappe.run_serially([ - () => frappe.timeout(0.1), - () => me.refresh_dependency(), - ]); - }); + .on( + "change input awesomplete-selectcomplete", + frappe.utils.debounce(() => { + this.dirty = true; + me.refresh_dependency(); + }, 100) + ); } } From b308ee813ab6eafc3b00b74d720025b4755ab201 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 8 Dec 2025 16:45:30 +0530 Subject: [PATCH 003/401] fix: per child level 'depends on' conditions --- frappe/public/js/frappe/form/grid.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 7cce820f60..5a38dc7e43 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -531,7 +531,7 @@ export default class Grid { grid_row = new GridRow({ parent: $rows, parent_df: this.df, - docfields: this.docfields, + docfields: JSON.parse(JSON.stringify(this.docfields)), doc: d, frm: this.frm, grid: this, From 721ef09dad316cb199166fea73f3127e19972209 Mon Sep 17 00:00:00 2001 From: DhavalGala999 Date: Thu, 11 Dec 2025 12:41:59 +0530 Subject: [PATCH 004/401] fix: safely handle null/NaN/empty in shorten_number Prevents 'Cannot read properties of null (reading "length")' in number cards when aggregate functions (AVG) return null or empty results. - Return empty string for null, undefined, empty string, NaN - Use digit count to decide if shortening is needed - Preserve behavior for valid numeric inputs --- frappe/public/js/frappe/utils/utils.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 49f9f77054..a50e8fa98a 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1449,8 +1449,19 @@ Object.assign(frappe.utils, { * max_no_of_decimals - max number of decimals of the shortened number */ - // return number if total digits is lesser than min_length - const len = String(number).match(/\d/g).length; + // return empty for null, undefined, or empty string + if (number == null || number === "") { + return ""; + } + + // extract digits from the number + const digits = String(number).match(/\d/g); + if (!digits) { + return ""; + } + + // return number if total digits are less than min_length + const len = digits.length; if (len < min_length) { return number.toString(); } From 7f5935d4057e4f3c3c06d3a17efa083639492d2b Mon Sep 17 00:00:00 2001 From: tridotstech Date: Sat, 13 Dec 2025 19:26:16 +0530 Subject: [PATCH 005/401] fix: restore custom_buttons tracking in add_custom_button Fixes #34920 --- frappe/public/js/frappe/form/form.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 249f957d94..d3f774c503 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1209,9 +1209,9 @@ frappe.ui.form.Form = class FrappeForm { this.dashboard.clear_headline(); this.dashboard.set_headline_alert( __("This form has been modified after you have loaded it") + - '", + '", "alert-warning" ); } else { @@ -1246,7 +1246,7 @@ frappe.ui.form.Form = class FrappeForm { add_web_link(path, label) { label = __(label) || __("See on Website"); this.web_link = this.sidebar - .add_user_action(__(label), function () {}) + .add_user_action(__(label), function () { }) .attr("href", path || this.doc.route) .attr("target", "_blank"); } @@ -1480,7 +1480,13 @@ frappe.ui.form.Form = class FrappeForm { if (group && group.indexOf("fa fa-") !== -1) group = null; let btn = this.page.add_inner_button(label, fn, group); + if (btn) { + let menu_item_label = group ? `${group} > ${label}` : label; + let menu_item = this.page.add_menu_item(menu_item_label, fn, false); + menu_item.parent().addClass("hidden-xl"); + this.custom_buttons[label] = btn; + } return btn; } @@ -2238,8 +2244,8 @@ frappe.ui.form.Form = class FrappeForm {
${__( + this.doctype + )}&ref_docname=${encodeURIComponent(this.docname)}'>${__( "All Submissions" )} `; From e8ed57df18477857982df790db552e368b5d3d21 Mon Sep 17 00:00:00 2001 From: tridotstech Date: Mon, 15 Dec 2025 11:30:21 +0530 Subject: [PATCH 006/401] style: apply prettier formatting --- frappe/public/js/frappe/form/form.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index d3f774c503..0c872db145 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1209,9 +1209,9 @@ frappe.ui.form.Form = class FrappeForm { this.dashboard.clear_headline(); this.dashboard.set_headline_alert( __("This form has been modified after you have loaded it") + - '", + '", "alert-warning" ); } else { @@ -1246,7 +1246,7 @@ frappe.ui.form.Form = class FrappeForm { add_web_link(path, label) { label = __(label) || __("See on Website"); this.web_link = this.sidebar - .add_user_action(__(label), function () { }) + .add_user_action(__(label), function () {}) .attr("href", path || this.doc.route) .attr("target", "_blank"); } @@ -2244,8 +2244,8 @@ frappe.ui.form.Form = class FrappeForm {
${__( + this.doctype + )}&ref_docname=${encodeURIComponent(this.docname)}'>${__( "All Submissions" )} `; From 481dc72f48c6467c3f47982056b01b7cbaffbd09 Mon Sep 17 00:00:00 2001 From: "Sambasiva Rao S(Platformatory)" Date: Fri, 19 Dec 2025 18:10:20 +0530 Subject: [PATCH 007/401] feat(link): use set_custom_query for route_options in new_doc - Refactored new_doc() to reuse set_custom_query instead of duplicating filter extraction logic - This properly handles link_filters, get_query (both object and function styles), and df.filters - Supports complex filter values like arrays (e.g., ['in', [...]]) --- frappe/public/js/frappe/form/controls/link.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index c0a626fedb..b8682f3a10 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -185,6 +185,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat frappe.route_options = df.get_route_options_for_new_doc(this); } else { frappe.route_options = {}; + // Reuse set_custom_query to extract filters from link_filters, get_query, and df.filters + let args = {}; + this.set_custom_query(args); + if (args.filters) { + Object.assign(frappe.route_options, args.filters); + } } // partially entered name field @@ -409,13 +415,13 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat let filter_string = this.df.filter_description ? this.df.filter_description : args.filters - ? this.get_filter_description(args.filters) - : null; + ? this.get_filter_description(args.filters) + : null; if (filter_string) { r.message.push({ html: `${filter_string}`, value: "", - action: () => {}, + action: () => { }, }); } From a74c70955baabedce85b61a1b079123c279626ce Mon Sep 17 00:00:00 2001 From: "Sambasiva Rao S(Platformatory)" Date: Fri, 19 Dec 2025 19:15:45 +0530 Subject: [PATCH 008/401] chore: format fix --- frappe/public/js/frappe/form/controls/link.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index b8682f3a10..dde3cd6907 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -415,13 +415,13 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat let filter_string = this.df.filter_description ? this.df.filter_description : args.filters - ? this.get_filter_description(args.filters) - : null; + ? this.get_filter_description(args.filters) + : null; if (filter_string) { r.message.push({ html: `${filter_string}`, value: "", - action: () => { }, + action: () => {}, }); } From 3a9d078dc33489f2d6cbd29ad6cb3667e7b2cfee Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Wed, 31 Dec 2025 10:48:48 +0530 Subject: [PATCH 009/401] fix(report_view): enforce print permission for reports --- frappe/public/js/frappe/microtemplate.js | 3 +++ frappe/public/js/frappe/views/reports/report_view.js | 1 + frappe/utils/print_format.py | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/microtemplate.js b/frappe/public/js/frappe/microtemplate.js index f3041c677a..7a1558c949 100644 --- a/frappe/public/js/frappe/microtemplate.js +++ b/frappe/public/js/frappe/microtemplate.js @@ -187,6 +187,9 @@ frappe.render_pdf = function (html, opts = {}) { //Push the HTML content into an element formData.append("html", html); + if (opts.doctype) { + formData.append("doctype", opts.doctype); + } if (opts.orientation) { formData.append("orientation", opts.orientation); } diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index a34249c1d8..78ab8f59a9 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -1531,6 +1531,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { }, { label: __("Print"), + condition: () => frappe.model.can_print(this.doctype), action: () => { // prepare rows in their current state, sorted and filtered const rows_in_order = this.datatable.datamanager.rowViewOrder diff --git a/frappe/utils/print_format.py b/frappe/utils/print_format.py index 59a94be98c..10afc78889 100644 --- a/frappe/utils/print_format.py +++ b/frappe/utils/print_format.py @@ -253,7 +253,9 @@ def download_pdf( @frappe.whitelist() -def report_to_pdf(html, orientation="Landscape"): +def report_to_pdf(html, orientation="Landscape", doctype=None): + if doctype: + frappe.has_permission(doctype, "print", throw=True) make_access_log(file_type="PDF", method="PDF", page=html) frappe.local.response.filename = "report.pdf" frappe.local.response.filecontent = get_pdf(html, {"orientation": orientation}) From ca4304f6ba91b480cb50648e24a1e2d8a2c48913 Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Fri, 2 Jan 2026 10:55:11 +0530 Subject: [PATCH 010/401] fix(data import): consider fieldname if label is null for col_build --- frappe/core/doctype/data_import/importer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index d7ff4250ff..f74022a5f6 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -1153,7 +1153,8 @@ def build_fields_dict_for_column_matching(parent_doctype): doctypes = [(parent_doctype, None)] + [(df.options, df) for df in parent_meta.get_table_fields()] for doctype, table_df in doctypes: - translated_table_label = _(table_df.label) if table_df else None + table_ref = (table_df.label or table_df.fieldname) if table_df else None + translated_table_label = _(table_ref) if table_ref else None # name field name_df = frappe._dict( @@ -1175,7 +1176,7 @@ def build_fields_dict_for_column_matching(parent_doctype): else: name_headers = ( f"{table_df.fieldname}.name", # fieldname - f"ID ({table_df.label})", # label + f"ID ({table_ref})", # label "{} ({})".format(_("ID"), translated_table_label), # translated label ) @@ -1229,7 +1230,7 @@ def build_fields_dict_for_column_matching(parent_doctype): # fieldname f"{table_df.fieldname}.{df.fieldname}", # label - f"{label} ({table_df.label})", + f"{label} ({table_ref})", # translated label f"{translated_label} ({translated_table_label})", ): From 207d55fb244a50b2e5116a54f5f5320985496ec0 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Fri, 2 Jan 2026 17:37:44 +0530 Subject: [PATCH 011/401] feat(Navbar): show title when available --- frappe/public/js/frappe/ui/page.js | 3 ++- frappe/public/js/frappe/views/breadcrumbs.js | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index 857bbcb4dd..95b6046f4b 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -770,7 +770,8 @@ frappe.ui.Page = class Page { if (icon) { title = `${frappe.utils.icon(icon)} ${title}`; } - let title_wrapper = this.$title_area.find(".title-text"); + + let title_wrapper = this.$title_area.find(".title-text-form"); title_wrapper.html(title); title_wrapper.attr("title", __(tooltip_label) || this.title); diff --git a/frappe/public/js/frappe/views/breadcrumbs.js b/frappe/public/js/frappe/views/breadcrumbs.js index abe1f60922..af4345e5f4 100644 --- a/frappe/public/js/frappe/views/breadcrumbs.js +++ b/frappe/public/js/frappe/views/breadcrumbs.js @@ -224,7 +224,8 @@ frappe.breadcrumbs = { if (docname.startsWith("new-" + doctype.toLowerCase().replace(/ /g, "-"))) { docname_title = __("New {0}", [__(doctype)]); } else { - docname_title = doc.name; + let title = frappe.model.get_doc_title(doc); + docname_title = title || doc.name; } this.append_breadcrumb_element(form_route, docname_title, "title-text-form"); @@ -238,7 +239,12 @@ frappe.breadcrumbs = { last_crumb.css("cursor", "copy"); last_crumb.click((event) => { event.stopImmediatePropagation(); - frappe.utils.copy_to_clipboard(last_crumb.text()); + frappe.utils.copy_to_clipboard(doc.name); + }); + last_crumb.attr("title", __("Click to copy name")); + last_crumb.tooltip({ + delay: { show: 100, hide: 100 }, + trigger: "hover", }); } }, From a8b615b278d851775d0761e494251c10b642e21f Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Fri, 2 Jan 2026 18:15:55 +0530 Subject: [PATCH 012/401] fix: allow setting cutom list title --- frappe/public/js/frappe/ui/page.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index 95b6046f4b..b2ab3dc581 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -771,7 +771,7 @@ frappe.ui.Page = class Page { title = `${frappe.utils.icon(icon)} ${title}`; } - let title_wrapper = this.$title_area.find(".title-text-form"); + let title_wrapper = this.$title_area.find(".title-text"); title_wrapper.html(title); title_wrapper.attr("title", __(tooltip_label) || this.title); From ffa2a6bfa59e3bf5adbefc725a011e48f683a0d4 Mon Sep 17 00:00:00 2001 From: trustedcomputer Date: Fri, 2 Jan 2026 16:05:46 -0800 Subject: [PATCH 013/401] fix(ui): add navigation buttons back --- frappe/core/doctype/user/user.json | 9 ++++++++- frappe/core/doctype/user/user.py | 2 ++ frappe/public/js/frappe/form/toolbar.js | 5 ++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 9a13ccb3a5..5454f5ac62 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -59,6 +59,7 @@ "view_switcher", "form_settings_section", "form_sidebar", + "form_navigation_buttons", "timeline", "dashboard", "show_absolute_datetime_in_timeline", @@ -850,6 +851,12 @@ "is_virtual": 1, "label": "Active Sessions", "options": "User Session Display" + }, + { + "default": "1", + "fieldname": "form_navigation_buttons", + "fieldtype": "Check", + "label": "Navigation Buttons" } ], "icon": "fa fa-user", @@ -903,7 +910,7 @@ } ], "make_attachments_public": 1, - "modified": "2025-12-13 12:53:46.486021", + "modified": "2026-01-02 16:00:51.406511", "modified_by": "Administrator", "module": "Core", "name": "User", diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 4fbc28a96a..1557b90ce8 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -48,6 +48,7 @@ desk_properties = ( "bulk_actions", "view_switcher", "form_sidebar", + "form_navigation_buttons", "timeline", "dashboard", ) @@ -96,6 +97,7 @@ class User(Document): follow_created_documents: DF.Check follow_liked_documents: DF.Check follow_shared_documents: DF.Check + form_navigation_buttons: DF.Check form_sidebar: DF.Check full_name: DF.Data | None gender: DF.Link | None diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index bf527655fd..1f65c44f30 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -318,9 +318,12 @@ frappe.ui.form.Toolbar = class Toolbar { this.page.clear_menu(); if (frappe.boot.desk_settings.form_sidebar) { - // this.make_navigation(); this.make_menu_items(); } + + if (frappe.boot.desk_settings.form_navigation_buttons) { + this.make_navigation(); + } } make_navigation() { From 8d99bed738703e712e11c9fac2285326ef7cc1bf Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Sat, 3 Jan 2026 13:54:27 +0530 Subject: [PATCH 014/401] fix(test): maintain sanity to avoid unexpected test failures in postgres --- frappe/tests/test_sqlite_search.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/tests/test_sqlite_search.py b/frappe/tests/test_sqlite_search.py index c4c528e294..8f6bfcee99 100644 --- a/frappe/tests/test_sqlite_search.py +++ b/frappe/tests/test_sqlite_search.py @@ -47,6 +47,8 @@ class TestSQLiteSearchAPI(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() + frappe.db.delete("Note") + frappe.db.delete("ToDo") cls.search = TestSQLiteSearch() # Clean up any existing test database cls.search.drop_index() From ee7c91599d2249d852d6feeb323c4b21dc231e21 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sat, 3 Jan 2026 16:21:49 +0530 Subject: [PATCH 015/401] refactor: telemetry with posthog and pulse providers --- frappe/api/__init__.py | 2 +- frappe/hooks.py | 2 +- frappe/public/js/telemetry/index.js | 85 ++++--------- frappe/public/js/telemetry/posthog.js | 81 ++++++++++++ frappe/public/js/telemetry/pulse.js | 118 ++++++++++++++++++ frappe/utils/telemetry/__init__.py | 57 +++++++++ .../{telemetry.py => telemetry/posthog.py} | 39 ++---- .../{ => utils/telemetry}/pulse/__init__.py | 0 .../telemetry}/pulse/app_heartbeat_event.py | 2 +- frappe/{ => utils/telemetry}/pulse/client.py | 3 +- frappe/{ => utils/telemetry}/pulse/utils.py | 0 11 files changed, 296 insertions(+), 93 deletions(-) create mode 100644 frappe/public/js/telemetry/posthog.js create mode 100644 frappe/public/js/telemetry/pulse.js create mode 100644 frappe/utils/telemetry/__init__.py rename frappe/utils/{telemetry.py => telemetry/posthog.py} (65%) rename frappe/{ => utils/telemetry}/pulse/__init__.py (100%) rename frappe/{ => utils/telemetry}/pulse/app_heartbeat_event.py (90%) rename frappe/{ => utils/telemetry}/pulse/client.py (97%) rename frappe/{ => utils/telemetry}/pulse/utils.py (100%) diff --git a/frappe/api/__init__.py b/frappe/api/__init__.py index 3c0906e039..1b7921cbee 100644 --- a/frappe/api/__init__.py +++ b/frappe/api/__init__.py @@ -11,8 +11,8 @@ import frappe from frappe import _ from frappe.modules.utils import get_doctype_app_map from frappe.monitor import add_data_to_monitor -from frappe.pulse.app_heartbeat_event import capture_app_heartbeat from frappe.utils.response import build_response +from frappe.utils.telemetry.pulse.app_heartbeat_event import capture_app_heartbeat class ApiVersion(str, Enum): diff --git a/frappe/hooks.py b/frappe/hooks.py index 966b80649d..62801f33bb 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -217,7 +217,7 @@ scheduler_events = { "frappe.automation.doctype.reminder.reminder.send_reminders", "frappe.model.utils.link_count.update_link_count", "frappe.search.sqlite_search.build_index_if_not_exists", - "frappe.pulse.client.send_queued_events", + "frappe.utils.telemetry.pulse.client.send_queued_events", ], # 10 minutes "0/10 * * * *": [ diff --git a/frappe/public/js/telemetry/index.js b/frappe/public/js/telemetry/index.js index db227ed189..0ea9199134 100644 --- a/frappe/public/js/telemetry/index.js +++ b/frappe/public/js/telemetry/index.js @@ -1,83 +1,48 @@ -import "../lib/posthog.js"; +import { posthog_provider } from "./posthog.js"; +import { pulse_provider } from "./pulse.js"; class TelemetryManager { constructor() { - this.enabled = false; + this.enabled = frappe.boot.enable_telemetry || false; + this.posthog_available = Boolean(frappe.boot.telemetry_provider?.includes("posthog")); + this.pulse_available = Boolean(frappe.boot.telemetry_provider?.includes("pulse")); - this.project_id = frappe.boot.posthog_project_id; - this.telemetry_host = frappe.boot.posthog_host; - this.site_age = frappe.boot.telemetry_site_age; - if (cint(frappe.boot.enable_telemetry) && this.project_id && this.telemetry_host) { - this.enabled = true; - } + this.init_providers(); } - initialize() { - if (!this.enabled) return; - let disable_decide = !this.should_record_session(); - try { - posthog.init(this.project_id, { - api_host: this.telemetry_host, - autocapture: false, - capture_pageview: false, - capture_pageleave: false, - advanced_disable_decide: disable_decide, - }); - posthog.identify(frappe.boot.sitename); - this.send_heartbeat(); - this.register_pageview_handler(); - } catch (e) { - console.trace("Failed to initialize telemetry", e); - this.enabled = false; + init_providers() { + this.providers = []; + + // Initialize posthog provider + posthog_provider.init(); + if (posthog_provider.enabled) { + this.providers.push(posthog_provider); + } + + // Initialize pulse provider + pulse_provider.init(); + if (pulse_provider.enabled) { + this.providers.push(pulse_provider); } } capture(event, app, props) { if (!this.enabled) return; - posthog.capture(`${app}_${event}`, props); + + for (let provider of this.providers) { + provider.capture(event, app, props); + } } disable() { this.enabled = false; + this.providers = []; } can_enable() { - let posthog_available = Boolean(this.telemetry_host && this.project_id); let sentry_available = Boolean(frappe.boot.sentry_dsn); - return posthog_available || sentry_available; - } - - send_heartbeat() { - const KEY = "ph_last_heartbeat"; - const now = frappe.datetime.system_datetime(true); - const last = localStorage.getItem(KEY); - - if (!last || moment(now).diff(moment(last), "hours") > 12) { - localStorage.setItem(KEY, now.toISOString()); - this.capture("heartbeat", "frappe", { frappe_version: frappe.boot?.versions?.frappe }); - } - } - - register_pageview_handler() { - if (this.site_age && this.site_age > 6) { - return; - } - - frappe.router.on("change", () => { - posthog.capture("$pageview"); - }); - } - - should_record_session() { - let start = frappe.boot.sysdefaults.session_recording_start; - if (!start) return; - - let start_datetime = frappe.datetime.str_to_obj(start); - let now = frappe.datetime.now_datetime(); - // if user allowed recording only record for first 2 hours, never again. - return frappe.datetime.get_minute_diff(now, start_datetime) < 120; + return this.posthog_available || this.pulse_available || sentry_available; } } frappe.telemetry = new TelemetryManager(); -frappe.telemetry.initialize(); diff --git a/frappe/public/js/telemetry/posthog.js b/frappe/public/js/telemetry/posthog.js new file mode 100644 index 0000000000..ba050dd569 --- /dev/null +++ b/frappe/public/js/telemetry/posthog.js @@ -0,0 +1,81 @@ +import "../lib/posthog.js"; + +class PosthogProvider { + constructor() { + this.enabled = false; + this.project_id = null; + this.telemetry_host = null; + } + + is_enabled() { + return ( + frappe.boot.telemetry_provider?.includes("posthog") && + frappe.boot.enable_telemetry && + Boolean(frappe.boot.posthog_project_id && frappe.boot.posthog_host) + ); + } + + init() { + if (!this.is_enabled()) return; + + this.project_id = frappe.boot.posthog_project_id; + this.telemetry_host = frappe.boot.posthog_host; + this.enabled = true; + + try { + let disable_decide = !this.should_record_session(); + posthog.init(this.project_id, { + api_host: this.telemetry_host, + autocapture: false, + capture_pageview: false, + capture_pageleave: false, + advanced_disable_decide: disable_decide, + }); + posthog.identify(frappe.boot.sitename); + this.send_heartbeat(); + this.register_pageview_handler(); + } catch (e) { + console.trace("Failed to initialize posthog telemetry", e); + this.enabled = false; + } + } + + capture(event, app, props) { + if (!this.enabled) return; + posthog.capture(`${app}_${event}`, props); + } + + send_heartbeat() { + const KEY = "ph_last_heartbeat"; + const now = frappe.datetime.system_datetime(true); + const last = localStorage.getItem(KEY); + + if (!last || moment(now).diff(moment(last), "hours") > 12) { + localStorage.setItem(KEY, now.toISOString()); + this.capture("heartbeat", "frappe", { frappe_version: frappe.boot?.versions?.frappe }); + } + } + + register_pageview_handler() { + const site_age = frappe.boot.telemetry_site_age; + if (site_age && site_age > 6) { + return; + } + + frappe.router.on("change", () => { + posthog.capture("$pageview"); + }); + } + + should_record_session() { + let start = frappe.boot.sysdefaults.session_recording_start; + if (!start) return; + + let start_datetime = frappe.datetime.str_to_obj(start); + let now = frappe.datetime.now_datetime(); + // if user allowed recording only record for first 2 hours, never again. + return frappe.datetime.get_minute_diff(now, start_datetime) < 120; + } +} + +export const posthog_provider = new PosthogProvider(); diff --git a/frappe/public/js/telemetry/pulse.js b/frappe/public/js/telemetry/pulse.js new file mode 100644 index 0000000000..b47fe9b6b8 --- /dev/null +++ b/frappe/public/js/telemetry/pulse.js @@ -0,0 +1,118 @@ +class PulseProvider { + constructor() { + this.enabled = false; + this.eq = null; + } + + is_enabled() { + return frappe.boot.telemetry_provider?.includes("pulse") && frappe.boot.enable_telemetry; + } + + init() { + if (!this.is_enabled()) return; + this.enabled = true; + + try { + this.eq = new QueueManager((events) => this.sendEvents(events), { + flushInterval: 10000, + }); + + // Send remaining events on unload + window.addEventListener("beforeunload", () => { + if (this.eq.queue.length) { + this.sendBeacon(this.eq.queue); + } + }); + } catch (error) { + // ignore errors + } + } + + capture(event, app, props) { + if (!this.enabled) return; + + this.eq.add({ + event_name: event, + app: app, + properties: props, + user: frappe.session.user, + captured_at: new Date().toISOString(), + }); + } + + sendEvents(events) { + try { + frappe.call({ + method: "frappe.utils.telemetry.pulse.client.bulk_capture", + args: { events }, + type: "POST", + no_spinner: true, + freeze: false, + error: () => {}, + always: () => {}, + }); + } catch (error) { + // ignore errors + } + } + + sendBeacon(events) { + try { + if (navigator.sendBeacon) { + const url = "/api/method/frappe.utils.telemetry.pulse.client.bulk_capture"; + const data = new FormData(); + data.append("events", JSON.stringify(events)); + navigator.sendBeacon(url, data); + } + } catch (error) { + // ignore errors + } + } +} + +class QueueManager { + constructor(flushCallback, options = {}) { + this.flushCallback = flushCallback; + this.queue = []; + this.maxQueueSize = options.maxQueueSize || 20; + this.flushInterval = options.flushInterval || 5000; + this.timer = null; + + this.start(); + } + + start() { + this.timer = setInterval(() => { + if (this.queue.length) this.flush(); + }, this.flushInterval); + } + + add(event) { + this.queue.push(event); + + if (this.queue.length >= this.maxQueueSize) { + this.flush(); + } + } + + flush() { + if (!this.queue.length) return; + + const batch = this.queue.splice(0); + try { + this.flushCallback(batch); + } catch (error) { + // ignore errors + } + } + + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.flush(); + } +} + +export const pulse_provider = new PulseProvider(); diff --git a/frappe/utils/telemetry/__init__.py b/frappe/utils/telemetry/__init__.py new file mode 100644 index 0000000000..9a6d831b49 --- /dev/null +++ b/frappe/utils/telemetry/__init__.py @@ -0,0 +1,57 @@ +"""Basic telemetry for improving apps. + +WARNING: Everything in this file should be treated "internal" and is subjected to change or get +removed without any warning. +""" + +import frappe +from frappe.utils import getdate +from frappe.utils.caching import site_cache + +# posthog provider +from .posthog import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD +from .posthog import capture as ph_capture +from .posthog import capture_doc as ph_capture_doc +from .posthog import init_telemetry as init_ph_telemetry +from .posthog import is_enabled as is_posthog_enabled + +# pulse provider +from .pulse.client import capture as pulse_capture +from .pulse.client import is_enabled as is_pulse_enabled + + +def add_bootinfo(bootinfo): + bootinfo.telemetry_site_age = site_age() + bootinfo.telemetry_provider = [] + + if is_posthog_enabled(): + bootinfo.enable_telemetry = True + bootinfo.telemetry_provider.append("posthog") + bootinfo.posthog_host = frappe.conf.get(POSTHOG_HOST_FIELD) + bootinfo.posthog_project_id = frappe.conf.get(POSTHOG_PROJECT_FIELD) + + if is_pulse_enabled(): + bootinfo.enable_telemetry = True + bootinfo.telemetry_provider.append("pulse") + + +def capture(event, app, **kwargs): + if is_posthog_enabled(): + ph_capture(event, app, **kwargs) + + if is_pulse_enabled(): + pulse_capture(event, app=app, **kwargs) + + +@site_cache(ttl=60 * 60 * 12) +def site_age(): + try: + est_creation = frappe.db.get_value("User", "Administrator", "creation") + return (getdate() - getdate(est_creation)).days + 1 + except Exception: + pass + + +# for backward compatibility +init_telemetry = init_ph_telemetry +capture_doc = ph_capture_doc diff --git a/frappe/utils/telemetry.py b/frappe/utils/telemetry/posthog.py similarity index 65% rename from frappe/utils/telemetry.py rename to frappe/utils/telemetry/posthog.py index 6578ca9319..77c11896ee 100644 --- a/frappe/utils/telemetry.py +++ b/frappe/utils/telemetry/posthog.py @@ -1,14 +1,7 @@ -"""Basic telemetry for improving apps. - -WARNING: Everything in this file should be treated "internal" and is subjected to change or get -removed without any warning. -""" - from contextlib import suppress from functools import lru_cache import frappe -from frappe.utils import getdate from frappe.utils.caching import site_cache from posthog import Posthog # isort: skip @@ -17,24 +10,13 @@ POSTHOG_PROJECT_FIELD = "posthog_project_id" POSTHOG_HOST_FIELD = "posthog_host" -def add_bootinfo(bootinfo): - bootinfo.telemetry_site_age = site_age() - - if not frappe.get_system_settings("enable_telemetry"): - return - - bootinfo.enable_telemetry = True - bootinfo.posthog_host = frappe.conf.get(POSTHOG_HOST_FIELD) - bootinfo.posthog_project_id = frappe.conf.get(POSTHOG_PROJECT_FIELD) - - -@site_cache(ttl=60 * 60 * 12) -def site_age(): - try: - est_creation = frappe.db.get_value("User", "Administrator", "creation") - return (getdate() - getdate(est_creation)).days + 1 - except Exception: - pass +@site_cache() +def is_enabled(): + return bool( + frappe.conf.get(POSTHOG_HOST_FIELD) + and frappe.conf.get(POSTHOG_PROJECT_FIELD) + and frappe.get_system_settings("enable_telemetry") + ) def init_telemetry(): @@ -42,15 +24,12 @@ def init_telemetry(): if hasattr(frappe.local, "posthog"): return - if not frappe.get_system_settings("enable_telemetry"): + if not is_enabled(): return posthog_host = frappe.conf.get(POSTHOG_HOST_FIELD) posthog_project_id = frappe.conf.get(POSTHOG_PROJECT_FIELD) - if not posthog_host or not posthog_project_id: - return - with suppress(Exception): frappe.local.posthog = _get_posthog_instance(posthog_project_id, posthog_host) @@ -78,6 +57,8 @@ def capture(event, app, **kwargs): def capture_doc(doc, action): + from frappe.utils.telemetry import site_age + with suppress(Exception): age = site_age() if not age or age > 15: diff --git a/frappe/pulse/__init__.py b/frappe/utils/telemetry/pulse/__init__.py similarity index 100% rename from frappe/pulse/__init__.py rename to frappe/utils/telemetry/pulse/__init__.py diff --git a/frappe/pulse/app_heartbeat_event.py b/frappe/utils/telemetry/pulse/app_heartbeat_event.py similarity index 90% rename from frappe/pulse/app_heartbeat_event.py rename to frappe/utils/telemetry/pulse/app_heartbeat_event.py index e971a5b860..8169084f49 100644 --- a/frappe/pulse/app_heartbeat_event.py +++ b/frappe/utils/telemetry/pulse/app_heartbeat_event.py @@ -1,7 +1,7 @@ import frappe -from frappe.pulse.utils import get_app_version, get_frappe_version from .client import capture, is_enabled +from .utils import get_app_version, get_frappe_version def capture_app_heartbeat(app): diff --git a/frappe/pulse/client.py b/frappe/utils/telemetry/pulse/client.py similarity index 97% rename from frappe/pulse/client.py rename to frappe/utils/telemetry/pulse/client.py index 7ffe3a6e28..adce9fa273 100644 --- a/frappe/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -4,11 +4,12 @@ from contextlib import suppress from orjson import JSONDecodeError import frappe -from frappe.pulse.utils import anonymize_user, ensure_http, parse_interval, utc_iso from frappe.utils import get_request_session from frappe.utils.caching import site_cache from frappe.utils.frappecloud import on_frappecloud +from .utils import anonymize_user, ensure_http, parse_interval, utc_iso + @frappe.whitelist() @site_cache() diff --git a/frappe/pulse/utils.py b/frappe/utils/telemetry/pulse/utils.py similarity index 100% rename from frappe/pulse/utils.py rename to frappe/utils/telemetry/pulse/utils.py From 80205d5d59c549fc230e8508e28860c7daec1d59 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sat, 3 Jan 2026 16:28:22 +0530 Subject: [PATCH 016/401] feat: whitelist capture methods for client side events --- frappe/utils/telemetry/__init__.py | 8 +-- frappe/utils/telemetry/pulse/client.py | 88 ++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/frappe/utils/telemetry/__init__.py b/frappe/utils/telemetry/__init__.py index 9a6d831b49..574c593dff 100644 --- a/frappe/utils/telemetry/__init__.py +++ b/frappe/utils/telemetry/__init__.py @@ -11,8 +11,8 @@ from frappe.utils.caching import site_cache # posthog provider from .posthog import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD from .posthog import capture as ph_capture -from .posthog import capture_doc as ph_capture_doc -from .posthog import init_telemetry as init_ph_telemetry +from .posthog import capture_doc as _ph_capture_doc +from .posthog import init_telemetry as _init_ph_telemetry from .posthog import is_enabled as is_posthog_enabled # pulse provider @@ -53,5 +53,5 @@ def site_age(): # for backward compatibility -init_telemetry = init_ph_telemetry -capture_doc = ph_capture_doc +init_telemetry = _init_ph_telemetry +capture_doc = _ph_capture_doc diff --git a/frappe/utils/telemetry/pulse/client.py b/frappe/utils/telemetry/pulse/client.py index adce9fa273..09fa1e60bc 100644 --- a/frappe/utils/telemetry/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -4,6 +4,7 @@ from contextlib import suppress from orjson import JSONDecodeError import frappe +from frappe.rate_limiter import rate_limit from frappe.utils import get_request_session from frappe.utils.caching import site_cache from frappe.utils.frappecloud import on_frappecloud @@ -14,21 +15,21 @@ from .utils import anonymize_user, ensure_http, parse_interval, utc_iso @frappe.whitelist() @site_cache() def is_enabled() -> bool: - return ( + return bool( not frappe.conf.get("developer_mode", 0) - and not frappe.conf.get("pulse_disabled", 0) and frappe.conf.get("pulse_api_key") and on_frappecloud() and frappe.get_system_settings("enable_telemetry") ) +@frappe.whitelist(allow_guest=True) def capture(event_name, site=None, app=None, user=None, properties=None, interval=None): if not is_enabled(): return try: - event_key = f"{event_name}:{site}:{app}:{user}" + event_key = f"{event_name}:{site or None}:{app or None}:{user or None}" if _is_ratelimited(event_key, interval): return @@ -44,7 +45,30 @@ def capture(event_name, site=None, app=None, user=None, properties=None, interva ) _update_ratelimit(event_key, interval) except Exception as e: - frappe.logger().error(f"Pulse event capture failed: {e!s}") + frappe.logger("pulse").error(f"pulse-client - capture failed: {e!s}") + + +@frappe.whitelist(allow_guest=True) +def bulk_capture(events): + if not is_enabled(): + return + + for event in events: + try: + # not supporting rate-limiting for bulk events + # so queue all events as-is + _queue_event( + { + "event_name": event.get("event_name"), + "captured_at": event.get("captured_at") or utc_iso(), + "site": frappe.local.site, + "app": event.get("app"), + "user": anonymize_user(event.get("user")), + "properties": event.get("properties"), + } + ) + except Exception as e: + frappe.logger("pulse").error(f"pulse-client - bulk capture failed for event {event}: {e!s}") def _is_ratelimited(event_key, interval): @@ -65,12 +89,12 @@ def _update_ratelimit(event_key, interval): if not interval: return last_sent_key = f"pulse-client:last_sent:{event_key}" - frappe.cache.set_value(last_sent_key, time.monotonic(), expires_in_sec=86400) # 24h TTL + frappe.cache.set_value(last_sent_key, time.monotonic()) def _queue_event(event): frappe.cache.lpush("pulse-client:events", frappe.as_json(event)) - frappe.cache.ltrim("pulse-client:events", 0, 4999) + frappe.cache.ltrim("pulse-client:events", 0, 9999) def queue_length(): @@ -81,7 +105,7 @@ def send_queued_events(): batch_size = 100 max_batches = 10 for _ in range(max_batches): - events = get_next_batch(batch_size) + events = collect_events(batch_size) if not events: break try: @@ -91,20 +115,25 @@ def send_queued_events(): frappe.logger().error(f"Pulse sending events failed: {e!s}") -def get_next_batch(batch_size=100): - """Get batch of events from the queue""" +def collect_events(batch_size=100): + """Pop batch of events from the queue""" events = [] for _ in range(batch_size): event_json = frappe.cache.rpop("pulse-client:events") if not event_json: break - event_json = event_json.decode() - with suppress(JSONDecodeError): - data = frappe.parse_json(event_json) + data = decode_event(event_json) + if data: events.append(data) return events +def decode_event(event_json): + event_json = event_json.decode() + with suppress(JSONDecodeError): + return frappe.parse_json(event_json) + + def post(events): # TODO: implement retry logic session = _create_session() @@ -135,3 +164,38 @@ def _get_ingest_url(): endpoint = endpoint.lstrip("/") return f"{host}/{endpoint}" + + +@frappe.whitelist() +def get_debug_info(fetch_events=None, fetch_rate_limited_events=None): + frappe.only_for("System Manager") + + info = frappe._dict() + info.is_enabled = is_enabled() + + if info.is_enabled: + info.queued_event_count = queue_length() + + if fetch_events: + info.queued_events = [] + limit = int(fetch_events) if str(fetch_events).isdigit() else 20 + for _ in range(min(limit, info.queued_event_count)): + event_json = frappe.cache.lindex("pulse-client:events", _) + data = decode_event(event_json) + if data: + info.queued_events.append(data) + + if fetch_rate_limited_events: + info.rate_limited_events = [] + limit = int(fetch_rate_limited_events) if str(fetch_rate_limited_events).isdigit() else 20 + for key in frappe.cache.get_keys("pulse-client:last_sent:*")[:limit]: + last_sent = frappe.cache.get_value(key) + event_key = key.replace("pulse-client:last_sent:", "") + info.rate_limited_events.append( + { + "event_key": event_key, + "last_sent": last_sent, + } + ) + + return info From 018d01fe77c77cdeda4738b406950cadb020a205 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sat, 3 Jan 2026 20:38:34 +0530 Subject: [PATCH 017/401] chore: remove unused import --- frappe/utils/telemetry/pulse/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/utils/telemetry/pulse/client.py b/frappe/utils/telemetry/pulse/client.py index 09fa1e60bc..8f0482db56 100644 --- a/frappe/utils/telemetry/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -4,7 +4,6 @@ from contextlib import suppress from orjson import JSONDecodeError import frappe -from frappe.rate_limiter import rate_limit from frappe.utils import get_request_session from frappe.utils.caching import site_cache from frappe.utils.frappecloud import on_frappecloud From aea1e86627ae121a905cfe8d17e8c0acdd97bc6a Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sat, 3 Jan 2026 20:49:52 +0530 Subject: [PATCH 018/401] fix: is_enabled is not updated on system settings change --- frappe/utils/telemetry/pulse/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/utils/telemetry/pulse/client.py b/frappe/utils/telemetry/pulse/client.py index 8f0482db56..78d96dcc13 100644 --- a/frappe/utils/telemetry/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -11,8 +11,7 @@ from frappe.utils.frappecloud import on_frappecloud from .utils import anonymize_user, ensure_http, parse_interval, utc_iso -@frappe.whitelist() -@site_cache() +@site_cache(ttl=60 * 60) def is_enabled() -> bool: return bool( not frappe.conf.get("developer_mode", 0) From 1f762c316a59b8ca723682e090d0f64fe17347a1 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sat, 3 Jan 2026 20:50:55 +0530 Subject: [PATCH 019/401] refactor: avoid guest event capture for now --- frappe/utils/telemetry/pulse/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/utils/telemetry/pulse/client.py b/frappe/utils/telemetry/pulse/client.py index 78d96dcc13..7228eb3559 100644 --- a/frappe/utils/telemetry/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -21,7 +21,7 @@ def is_enabled() -> bool: ) -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def capture(event_name, site=None, app=None, user=None, properties=None, interval=None): if not is_enabled(): return @@ -46,7 +46,7 @@ def capture(event_name, site=None, app=None, user=None, properties=None, interva frappe.logger("pulse").error(f"pulse-client - capture failed: {e!s}") -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def bulk_capture(events): if not is_enabled(): return From 3ae959c651311fb57db15925aad1be62516190f9 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sat, 3 Jan 2026 21:08:51 +0530 Subject: [PATCH 020/401] refactor: add retry mechanism --- frappe/public/js/telemetry/pulse.js | 75 ++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/frappe/public/js/telemetry/pulse.js b/frappe/public/js/telemetry/pulse.js index b47fe9b6b8..6cab492a57 100644 --- a/frappe/public/js/telemetry/pulse.js +++ b/frappe/public/js/telemetry/pulse.js @@ -19,9 +19,8 @@ class PulseProvider { // Send remaining events on unload window.addEventListener("beforeunload", () => { - if (this.eq.queue.length) { - this.sendBeacon(this.eq.queue); - } + const events = this.eq?.getBufferedEvents?.() || []; + if (events.length) this.sendBeacon(events); }); } catch (error) { // ignore errors @@ -41,19 +40,22 @@ class PulseProvider { } sendEvents(events) { - try { - frappe.call({ - method: "frappe.utils.telemetry.pulse.client.bulk_capture", - args: { events }, - type: "POST", - no_spinner: true, - freeze: false, - error: () => {}, - always: () => {}, - }); - } catch (error) { - // ignore errors - } + // Return a Promise so QueueManager can retry on failure. + return new Promise((resolve, reject) => { + try { + frappe.call({ + method: "frappe.utils.telemetry.pulse.client.bulk_capture", + args: { events }, + type: "POST", + no_spinner: true, + freeze: false, + callback: () => resolve(), + error: (error) => reject(error), + }); + } catch (error) { + reject(error); + } + }); } sendBeacon(events) { @@ -74,16 +76,27 @@ class QueueManager { constructor(flushCallback, options = {}) { this.flushCallback = flushCallback; this.queue = []; + this.pendingBatch = null; + this.retryAttempts = 0; + this.maxRetries = 3; this.maxQueueSize = options.maxQueueSize || 20; this.flushInterval = options.flushInterval || 5000; this.timer = null; + this.flushing = false; this.start(); } + getBufferedEvents() { + const events = []; + if (this.pendingBatch?.length) events.push(...this.pendingBatch); + if (this.queue.length) events.push(...this.queue); + return events; + } + start() { this.timer = setInterval(() => { - if (this.queue.length) this.flush(); + if (this.queue.length || this.pendingBatch) this.flush(); }, this.flushInterval); } @@ -95,14 +108,30 @@ class QueueManager { } } - flush() { - if (!this.queue.length) return; + async flush() { + if (this.flushing) return; + this.flushing = true; - const batch = this.queue.splice(0); try { - this.flushCallback(batch); - } catch (error) { - // ignore errors + if (!this.pendingBatch) { + if (!this.queue.length) return; + this.pendingBatch = this.queue.splice(0, this.maxQueueSize); + this.retryAttempts = 0; + } + + try { + await this.flushCallback(this.pendingBatch); + this.pendingBatch = null; + this.retryAttempts = 0; + } catch (error) { + this.retryAttempts++; + if (this.retryAttempts > this.maxRetries) { + this.pendingBatch = null; + this.retryAttempts = 0; + } + } + } finally { + this.flushing = false; } } From b82b336891bbe1b02ee7a3e1295c046b41cde5ad Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Sun, 16 Nov 2025 20:39:48 +0000 Subject: [PATCH 021/401] feat: Raw HTML emails --- .../public/js/frappe/views/communication.js | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 58e7456d2a..286bf6ac52 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -127,6 +127,15 @@ frappe.views.CommunicationComposer = class { fieldtype: "Text Editor", fieldname: "content", onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300), + depends_on: "eval:!doc.use_html", + }, + { + label: __("HTML Message"), + fieldtype: "Code", + fieldname: "html_content", + onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300), + depends_on: "eval:doc.use_html", + options: "HTML", }, { label: __("Message"), @@ -180,6 +189,19 @@ frappe.views.CommunicationComposer = class { depends_on: "attach_document_print", }, { fieldtype: "Column Break" }, + { + label: __("Use HTML"), + fieldtype: "Check", + fieldname: "use_html", + default: 0, + onchange: function (e) { + if (e.target.checked) { + me.dialog.set_value("html_content", me.dialog.get_value("content")); + } else { + me.dialog.set_value("content", me.dialog.get_value("html_content")); + } + }, + }, { label: __("Select Attachments"), fieldtype: "HTML", @@ -520,7 +542,7 @@ frappe.views.CommunicationComposer = class { if (this.message) return; const last_edited = this.get_last_edited_communication(); - if (!last_edited.content && !last_edited.content_html) return; + if (!last_edited.content && !last_edited.html_content) return; // prevent re-triggering of email template if (last_edited.email_template) { From 75b481a4f9e174db15f234c8ee57f9bf39f04e2b Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 19 Nov 2025 08:09:36 +0000 Subject: [PATCH 022/401] feat: Inject footer and header within an HTML Email Template --- .../doctype/email_template/email_template.py | 25 ++++++++++++++++--- .../public/js/frappe/views/communication.js | 1 + 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index b37598e051..b06e5c3977 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -37,19 +37,38 @@ class EmailTemplate(Document): def get_formatted_response(self, doc): return frappe.render_template(self.response_, doc) - def get_formatted_email(self, doc): + def get_formatted_email(self, doc, sender=None): if isinstance(doc, str): doc = json.loads(doc) + if self.use_html: + doc = self.inject_email_account(doc, sender) + return { "subject": self.get_formatted_subject(doc), "message": self.get_formatted_response(doc), } + def inject_email_account(self, doc, sender=None): + from frappe.email.doctype.email_account.email_account import EmailAccount + from frappe.email.email_body import get_footer, get_signature + + if sender: + kwargs = {"match_by_email": sender} + else: + kwargs = {"match_by_doctype": doc.get("doctype")} + email_account = EmailAccount.find_outgoing(**kwargs) + + if email_account: + doc.update( + {"email_signature": get_signature(email_account), "email_footer": get_footer(email_account)} + ) + return doc + @frappe.whitelist() -def get_email_template(template_name, doc): +def get_email_template(template_name, doc, sender=None): """Return the processed HTML of a email template with the given doc""" email_template = frappe.get_doc("Email Template", template_name) - return email_template.get_formatted_email(doc) + return email_template.get_formatted_email(doc, sender=sender) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 286bf6ac52..0d3c7fe1be 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -450,6 +450,7 @@ frappe.views.CommunicationComposer = class { args: { template_name: email_template, doc: me.doc, + sender: me.dialog.get_value("sender") || "", }, callback(r) { prepend_reply(r.message); From e835f1c7c9b316f17cdc87f3c11b4b3bf452b07c Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 19 Nov 2025 14:23:06 +0000 Subject: [PATCH 023/401] feat: Add raw_html parameter to all relevant doctypes --- frappe/core/doctype/communication/email.py | 5 ++ frappe/core/doctype/communication/mixins.py | 4 ++ frappe/core/doctype/user/user.py | 2 +- frappe/email/__init__.py | 3 + .../doctype/email_queue/email_queue.json | 14 ++++- .../email/doctype/email_queue/email_queue.py | 6 ++ frappe/email/email_body.py | 56 ++++++++++++------- .../public/js/frappe/views/communication.js | 1 + frappe/utils/jinja.py | 1 + 9 files changed, 68 insertions(+), 24 deletions(-) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index d2b79224dd..55e3d2c125 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -50,6 +50,7 @@ def make( send_after=None, print_language=None, now=False, + raw_html=False, **kwargs, ) -> dict[str, str]: """Make a new communication. Checks for email permissions for specified Document. @@ -69,6 +70,7 @@ def make( :param send_me_a_copy: Send a copy to the sender (default **False**). :param email_template: Template which is used to compose mail . :param send_after: Send after the given datetime. + :param raw_html: Whether to use html version of email template """ if kwargs: from frappe.utils.commands import warn @@ -107,6 +109,7 @@ def make( send_after=send_after, print_language=print_language, now=now, + raw_html=raw_html, ) @@ -135,6 +138,7 @@ def _make( send_after=None, print_language=None, now=False, + raw_html=False, ) -> dict[str, str]: """Internal method to make a new communication that ignores Permission checks.""" @@ -190,6 +194,7 @@ def _make( print_letterhead=print_letterhead, print_language=print_language, now=now, + raw_html=raw_html, ) emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy) diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index 4318a451e5..68dd0bce06 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -258,6 +258,7 @@ class CommunicationEmailMixin: print_letterhead=None, is_inbound_mail_communcation=None, print_language=None, + raw_html=False, ) -> dict: outgoing_email_account = self.get_outgoing_email_account() if not outgoing_email_account: @@ -307,6 +308,7 @@ class CommunicationEmailMixin: "is_notification": (self.sent_or_received == "Received"), "print_letterhead": print_letterhead, "send_after": self.send_after, + "raw_html": raw_html, } def send_email( @@ -318,6 +320,7 @@ class CommunicationEmailMixin: is_inbound_mail_communcation=None, print_language=None, now=False, + raw_html=False, ): if input_dict := self.sendmail_input_dict( print_html=print_html, @@ -326,5 +329,6 @@ class CommunicationEmailMixin: print_letterhead=print_letterhead, is_inbound_mail_communcation=is_inbound_mail_communcation, print_language=print_language, + raw_html=raw_html, ): frappe.sendmail(now=now, **input_dict) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 4fbc28a96a..79987fe22b 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -555,7 +555,7 @@ class User(Document): if custom_template: from frappe.email.doctype.email_template.email_template import get_email_template - email_template = get_email_template(custom_template, args) + email_template = get_email_template(custom_template, args, sender=sender) subject = email_template.get("subject") content = email_template.get("message") diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index c73ae65f04..ad5198bf87 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -150,6 +150,7 @@ def sendmail( email_read_tracker_url=None, x_priority: Literal[1, 3, 5] = 3, email_headers=None, + raw_html=False, ) -> EmailQueue | None: """Send email using user's default **Email Account** or global default **Email Account**. @@ -179,6 +180,7 @@ def sendmail( :param with_container: Wraps email inside a styled container :param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST :param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present. + :param raw_html: Whether to treat email template as a complete HTML file """ from frappe.utils.jinja import get_email_from_template @@ -238,6 +240,7 @@ def sendmail( email_read_tracker_url=email_read_tracker_url, x_priority=x_priority, email_headers=email_headers, + raw_html=raw_html, ) # build email queue and send the email if send_now is True. diff --git a/frappe/email/doctype/email_queue/email_queue.json b/frappe/email/doctype/email_queue/email_queue.json index cc01c9f56a..1379c1fcb6 100644 --- a/frappe/email/doctype/email_queue/email_queue.json +++ b/frappe/email/doctype/email_queue/email_queue.json @@ -25,7 +25,8 @@ "expose_recipients", "attachments", "retry", - "email_account" + "email_account", + "raw_html" ], "fields": [ { @@ -148,13 +149,20 @@ "fieldtype": "Code", "label": "Unsubscribe Params", "read_only": 1 + }, + { + "default": "0", + "fieldname": "raw_html", + "fieldtype": "Check", + "label": "Send As Raw HTML", + "read_only": 1 } ], "icon": "fa fa-envelope", "idx": 1, "in_create": 1, "links": [], - "modified": "2025-03-07 15:56:13.341958", + "modified": "2025-11-19 11:18:45.574190", "modified_by": "Administrator", "module": "Email", "name": "Email Queue", @@ -175,4 +183,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 7df03de990..3a85e5ad38 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -60,6 +60,7 @@ class EmailQueue(Document): message: DF.Code | None message_id: DF.SmallText | None priority: DF.Int + raw_html: DF.Check recipients: DF.Table[EmailQueueRecipient] reference_doctype: DF.Link | None reference_name: DF.Data | None @@ -518,6 +519,7 @@ class QueueBuilder: email_read_tracker_url=None, x_priority: Literal[1, 3, 5] = 3, email_headers=None, + raw_html=False, ): """Add email to sending queue (Email Queue) @@ -545,6 +547,7 @@ class QueueBuilder: :param email_read_tracker_url: A URL for tracking whether an email is read by the recipient. :param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST :param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present. + :param raw_html: Whether to treat email template as a complete HTML file """ self._unsubscribe_method = unsubscribe_method @@ -582,6 +585,7 @@ class QueueBuilder: self.print_letterhead = print_letterhead self.email_read_tracker_url = email_read_tracker_url self.email_headers = email_headers + self.raw_html = raw_html @property def unsubscribe_method(self): @@ -638,6 +642,7 @@ class QueueBuilder: email_account=email_account, unsubscribe_link=self.unsubscribe_message(), with_container=self.with_container, + raw_html=self.raw_html, ) def should_include_unsubscribe_link(self): @@ -843,6 +848,7 @@ class QueueBuilder: "show_as_bcc": ",".join(self.final_bcc()), "email_account": email_account_name or None, "email_read_tracker_url": self.email_read_tracker_url, + "raw_html": self.raw_html, } if include_recipients: diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 707d301546..713004845a 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -381,29 +381,42 @@ def get_formatted_html( unsubscribe_link: frappe._dict | None = None, sender=None, with_container=False, + raw_html=False, ): email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender) - rendered_email = frappe.get_template("templates/emails/standard.html").render( - { - "brand_logo": get_brand_logo(email_account) if with_container or header else None, - "with_container": with_container, - "site_url": get_url(), - "header": get_header(header), - "content": message, - "footer": get_footer(email_account, footer), - "title": subject, - "print_html": print_html, - "subject": subject, - } - ) + if raw_html: + rendered_email = frappe.render_template( + message, + { + "site_url": get_url(), + "title": subject, + "print_html": print_html, + "subject": subject, + }, + ) + + else: + rendered_email = frappe.get_template("templates/emails/standard.html").render( + { + "brand_logo": get_brand_logo(email_account) if with_container or header else None, + "with_container": with_container, + "site_url": get_url(), + "header": get_header(header), + "content": message, + "footer": get_footer(email_account, footer), + "title": subject, + "print_html": print_html, + "subject": subject, + } + ) html = scrub_urls(rendered_email) if unsubscribe_link: html = html.replace("", unsubscribe_link.html) - return inline_style_in_html(html) + return inline_style_in_html(html, add_css=not raw_html) @frappe.whitelist() @@ -418,17 +431,20 @@ def get_email_html(template, args, subject, header=None, with_container=False): return get_formatted_html(subject, email[0], header=header, with_container=with_container) -def inline_style_in_html(html): +def inline_style_in_html(html, add_css=True): """Convert email.css and html to inline-styled html.""" from premailer import Premailer from frappe.utils.jinja_globals import bundled_asset - # get email css files from hooks - css_files = frappe.get_hooks("email_css") - css_files = [bundled_asset(path) for path in css_files] - css_files = [path.lstrip("/") for path in css_files] - css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))] + if add_css: + # get email css files from hooks + css_files = frappe.get_hooks("email_css") + css_files = [bundled_asset(path) for path in css_files] + css_files = [path.lstrip("/") for path in css_files] + css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))] + else: + css_files = None p = Premailer( html=html, external_styles=css_files, strip_important=False, allow_loading_external_files=True diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 0d3c7fe1be..35bc700446 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -846,6 +846,7 @@ frappe.views.CommunicationComposer = class { print_letterhead: me.is_print_letterhead_checked(), send_after: form_values.send_after ? form_values.send_after : null, print_language: form_values.print_language, + raw_html: form_values.use_html, }, btn, callback(r) { diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py index b8c979796a..7ef65d5902 100644 --- a/frappe/utils/jinja.py +++ b/frappe/utils/jinja.py @@ -134,6 +134,7 @@ def render_template(template, context=None, is_path=None, safe_render=True): title="Jinja Template Error", msg=f"
{template}
{html.escape(get_traceback())}
", ) + return "" import time From dd50155b89e0d0c81641f9fbd094c39df475af93 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Thu, 20 Nov 2025 12:21:19 +0000 Subject: [PATCH 024/401] fix: Add Server-side safety checks on use of Raw HTML messages --- frappe/core/doctype/communication/email.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 55e3d2c125..0f8e0f6e22 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -72,9 +72,9 @@ def make( :param send_after: Send after the given datetime. :param raw_html: Whether to use html version of email template """ - if kwargs: - from frappe.utils.commands import warn + from frappe.utils.commands import warn + if kwargs: warn( f"Options {kwargs} used in frappe.core.doctype.communication.email.make " "are deprecated or unsupported", @@ -84,6 +84,16 @@ def make( if doctype and name: frappe.has_permission(doctype, doc=name, ptype="email", throw=True) + if raw_html and email_template and not frappe.get_value("Email Template", email_template, "use_html"): + warn( + _( + "Raw HTML can be used only with Email Templates having 'Use HTML' checked. " + "Proceeding with plain text email." + ), + category=UserWarning, + ) + raw_html = False + return _make( doctype=doctype, name=name, @@ -169,7 +179,9 @@ def _make( "send_after": send_after, } ) - comm.flags.skip_add_signature = not add_signature + comm.flags.skip_add_signature = not add_signature or ( + raw_html and frappe.get_value("Email Template", email_template, "use_html") + ) comm.insert(ignore_permissions=True) # if not committed, delayed task doesn't find the communication From 9b75fa54878a5a1c74d6f2470220e220b082553b Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Thu, 20 Nov 2025 12:25:29 +0000 Subject: [PATCH 025/401] fix: UI/UX improvements wrt 'Use HTML' toggle --- .../public/js/frappe/views/communication.js | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 35bc700446..95e8633217 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -108,12 +108,48 @@ frappe.views.CommunicationComposer = class { fieldtype: "Link", options: "Email Template", fieldname: "email_template", + onchange: function () { + const email_template = this.value; + if (!email_template) { + return me.hide_use_html_field(); + } + + frappe.db + .get_value("Email Template", email_template, "use_html") + .then((r) => { + // Show or hide "Use HTML" based on the Email Template's use_html value + if (r.message?.use_html === 1) { + // Show the field. + me.dialog.fields_dict.use_html.toggle(true); + } else { + me.hide_use_html_field(); + } + }) + .catch((e) => { + console.error("Failed to load template", e); + me.hide_use_html_field(); + }); + }, }, { fieldtype: "HTML", label: __("Clear & Add template"), fieldname: "clear_and_add_template", }, + { + label: __("Use HTML"), + fieldtype: "Check", + fieldname: "use_html", + default: 0, + hidden: 1, + onchange: function (e) { + if (e.target.checked) { + me.dialog.set_value("html_content", me.dialog.get_value("content")); + } else { + me.dialog.set_value("content", me.dialog.get_value("html_content")); + } + }, + }, { fieldtype: "Section Break" }, { label: __("Subject"), @@ -189,19 +225,6 @@ frappe.views.CommunicationComposer = class { depends_on: "attach_document_print", }, { fieldtype: "Column Break" }, - { - label: __("Use HTML"), - fieldtype: "Check", - fieldname: "use_html", - default: 0, - onchange: function (e) { - if (e.target.checked) { - me.dialog.set_value("html_content", me.dialog.get_value("content")); - } else { - me.dialog.set_value("content", me.dialog.get_value("html_content")); - } - }, - }, { label: __("Select Attachments"), fieldtype: "HTML", @@ -276,6 +299,11 @@ frappe.views.CommunicationComposer = class { this.dialog.set_value("print_language", lang); } + hide_use_html_field() { + this.dialog.fields_dict.use_html.set_input(false); // reset the value + this.dialog.fields_dict.use_html.toggle(false); // hide the field + } + toggle_more_options(show_options) { show_options = show_options || this.dialog.fields_dict.more_options.df.hidden; this.dialog.set_df_property("more_options", "hidden", !show_options); @@ -475,6 +503,7 @@ frappe.views.CommunicationComposer = class { ]; frappe.utils.add_select_group_button(clear_and_add_template, email_template_actions); + $(fields.use_html.wrapper).addClass("mt-2 text-center").appendTo(clear_and_add_template); } setup_last_edited_communication() { From a1cb7430e893cde56fcbf33ea6b64c8233bd211b Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 26 Nov 2025 16:22:43 +0000 Subject: [PATCH 026/401] fix: Email Dialog use_html field hidden after re-opening --- .../public/js/frappe/views/communication.js | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 95e8633217..20a4ae6ab1 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -108,27 +108,12 @@ frappe.views.CommunicationComposer = class { fieldtype: "Link", options: "Email Template", fieldname: "email_template", - onchange: function () { + onchange: async function () { const email_template = this.value; if (!email_template) { return me.hide_use_html_field(); } - - frappe.db - .get_value("Email Template", email_template, "use_html") - .then((r) => { - // Show or hide "Use HTML" based on the Email Template's use_html value - if (r.message?.use_html === 1) { - // Show the field. - me.dialog.fields_dict.use_html.toggle(true); - } else { - me.hide_use_html_field(); - } - }) - .catch((e) => { - console.error("Failed to load template", e); - me.hide_use_html_field(); - }); + await me.check_email_template_html(email_template); }, }, { @@ -143,6 +128,7 @@ frappe.views.CommunicationComposer = class { default: 0, hidden: 1, onchange: function (e) { + if (!e) return; if (e.target.checked) { me.dialog.set_value("html_content", me.dialog.get_value("content")); } else { @@ -299,6 +285,17 @@ frappe.views.CommunicationComposer = class { this.dialog.set_value("print_language", lang); } + async check_email_template_html(email_template) { + const r = await frappe.db.get_value("Email Template", email_template, "use_html"); + // Show or hide "Use HTML" based on the Email Template's use_html value + if (r.message?.use_html === 1) { + // Show the field. + this.dialog.fields_dict.use_html.toggle(true); + } else { + this.hide_use_html_field(); + } + } + hide_use_html_field() { this.dialog.fields_dict.use_html.set_input(false); // reset the value this.dialog.fields_dict.use_html.toggle(false); // hide the field @@ -469,7 +466,7 @@ frappe.views.CommunicationComposer = class { let content = content_field.get_value() || ""; - content_field.set_value(`${reply.message}
${content}`); + content_field.set_value(reply.message + content); subject_field.set_value(reply.subject); } @@ -578,6 +575,7 @@ frappe.views.CommunicationComposer = class { if (last_edited.email_template) { const template_field = this.dialog.fields_dict.email_template; await template_field.set_model_value(last_edited.email_template); + await this.check_email_template_html(last_edited.email_template); delete last_edited.email_template; } From 79eedf4fb71f2a31623f94f0650212e4d0f27e43 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Thu, 27 Nov 2025 08:40:59 +0000 Subject: [PATCH 027/401] refactor: Add get_content_field function to communication.js --- frappe/public/js/frappe/views/communication.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 20a4ae6ab1..38456fa9d7 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -252,6 +252,13 @@ frappe.views.CommunicationComposer = class { return fields; } + get_content_field() { + const content_field = this.dialog.fields_dict.use_html.value + ? this.dialog.fields_dict.html_content + : this.dialog.fields_dict.content; + return content_field; + } + get_default_recipients(fieldname) { if (this.frm?.events.get_email_recipients) { return (this.frm.events.get_email_recipients(this.frm, fieldname) || []).join(", "); From 2f62168654af021af7664f41dbd1a1e5f8efcae5 Mon Sep 17 00:00:00 2001 From: Packeting <127834955+Packeting1@users.noreply.github.com> Date: Sun, 4 Jan 2026 11:17:30 +0800 Subject: [PATCH 028/401] fix: show form button in mobile print view --- frappe/printing/page/print/print.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index e20859348a..a3b8fba88b 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -88,14 +88,16 @@ frappe.ui.form.PrintView = class { icon: "refresh", }); - this.page.add_action_icon( - "es-line-filetype", - () => { - this.go_to_form_view(); - }, - "", - __("Form") - ); + if (frappe.is_mobile()) { + this.page.add_button(__("Form"), () => this.go_to_form_view(), { icon: "small-file" }); + } else { + this.page.add_action_icon( + "es-line-filetype", + () => this.go_to_form_view(), + "", + __("Form") + ); + } } setup_sidebar() { From 8d8fa78bee65825ce5033b1d21fd67b09f12baca Mon Sep 17 00:00:00 2001 From: Aarol D'Souza <98270103+AarDG10@users.noreply.github.com> Date: Sun, 4 Jan 2026 10:18:53 +0530 Subject: [PATCH 029/401] fix(postgres): add pg compatible query for copying comm_date from comm to comm_link (#35488) --- .../copy_communication_date_to_link.py | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/frappe/core/doctype/communication_link/patches/copy_communication_date_to_link.py b/frappe/core/doctype/communication_link/patches/copy_communication_date_to_link.py index 0a6cd1ee5e..563acd8f8d 100644 --- a/frappe/core/doctype/communication_link/patches/copy_communication_date_to_link.py +++ b/frappe/core/doctype/communication_link/patches/copy_communication_date_to_link.py @@ -6,18 +6,32 @@ def execute(): batch_size = 10_000 while True: - frappe.db.sql( - """ - update `tabCommunication Link` cl - inner join `tabCommunication` c on cl.parent = c.name - set cl.communication_date = c.communication_date - where cl.communication_date is null - and c.communication_date is not null - limit %s - """, + frappe.db.multisql( + { + "mariadb": """ + update `tabCommunication Link` cl + inner join `tabCommunication` c on cl.parent = c.name + set cl.communication_date = c.communication_date + where cl.communication_date is null + and c.communication_date is not null + limit %s + """, + "postgres": """ + UPDATE "tabCommunication Link" + SET communication_date = sub.communication_date + FROM ( + SELECT cl.name, c.communication_date + FROM "tabCommunication Link" cl + JOIN "tabCommunication" c ON cl.parent = c.name + WHERE cl.communication_date IS NULL + AND c.communication_date IS NOT NULL + LIMIT %s + ) AS sub + WHERE "tabCommunication Link".name = sub.name + """, + }, (batch_size,), ) - frappe.db.commit() if not frappe.db.sql( From f3cc4301f8076700cebc03fcba8db307e481b199 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sun, 4 Jan 2026 12:39:08 +0530 Subject: [PATCH 030/401] refactor: create event queue class --- frappe/utils/telemetry/pulse/client.py | 223 ++++++++++++++----------- 1 file changed, 122 insertions(+), 101 deletions(-) diff --git a/frappe/utils/telemetry/pulse/client.py b/frappe/utils/telemetry/pulse/client.py index 7228eb3559..6341e8262a 100644 --- a/frappe/utils/telemetry/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -22,26 +22,23 @@ def is_enabled() -> bool: @frappe.whitelist() -def capture(event_name, site=None, app=None, user=None, properties=None, interval=None): +def capture(event_name, site=None, app=None, user=None, captured_at=None, properties=None, interval=None): if not is_enabled(): return try: - event_key = f"{event_name}:{site or None}:{app or None}:{user or None}" - if _is_ratelimited(event_key, interval): - return - - _queue_event( + eq = EventQueue() + eq.add( { "event_name": event_name, - "captured_at": utc_iso(), + "captured_at": captured_at or utc_iso(), "app": app, "user": anonymize_user(user), "site": site or frappe.local.site, "properties": properties, - } + }, + interval=interval, ) - _update_ratelimit(event_key, interval) except Exception as e: frappe.logger("pulse").error(f"pulse-client - capture failed: {e!s}") @@ -52,84 +49,23 @@ def bulk_capture(events): return for event in events: - try: - # not supporting rate-limiting for bulk events - # so queue all events as-is - _queue_event( - { - "event_name": event.get("event_name"), - "captured_at": event.get("captured_at") or utc_iso(), - "site": frappe.local.site, - "app": event.get("app"), - "user": anonymize_user(event.get("user")), - "properties": event.get("properties"), - } - ) - except Exception as e: - frappe.logger("pulse").error(f"pulse-client - bulk capture failed for event {event}: {e!s}") - - -def _is_ratelimited(event_key, interval): - if not interval: - return False - - interval_seconds = parse_interval(interval) - last_sent_key = f"pulse-client:last_sent:{event_key}" - last_sent = frappe.cache.get_value(last_sent_key) - - if last_sent and time.monotonic() - float(last_sent) < interval_seconds: - return True - - return False - - -def _update_ratelimit(event_key, interval): - if not interval: - return - last_sent_key = f"pulse-client:last_sent:{event_key}" - frappe.cache.set_value(last_sent_key, time.monotonic()) - - -def _queue_event(event): - frappe.cache.lpush("pulse-client:events", frappe.as_json(event)) - frappe.cache.ltrim("pulse-client:events", 0, 9999) - - -def queue_length(): - return frappe.cache.llen("pulse-client:events") + capture( + event.get("event_name"), + site=event.get("site"), + app=event.get("app"), + user=event.get("user"), + captured_at=event.get("captured_at"), + properties=event.get("properties"), + interval=event.get("interval"), + ) def send_queued_events(): - batch_size = 100 - max_batches = 10 - for _ in range(max_batches): - events = collect_events(batch_size) - if not events: - break - try: - if not post(events): - frappe.logger().error("Pulse sending events failed: non-2xx response") - except Exception as e: - frappe.logger().error(f"Pulse sending events failed: {e!s}") + if not is_enabled(): + return - -def collect_events(batch_size=100): - """Pop batch of events from the queue""" - events = [] - for _ in range(batch_size): - event_json = frappe.cache.rpop("pulse-client:events") - if not event_json: - break - data = decode_event(event_json) - if data: - events.append(data) - return events - - -def decode_event(event_json): - event_json = event_json.decode() - with suppress(JSONDecodeError): - return frappe.parse_json(event_json) + eq = EventQueue() + eq.batch_process(post, batch_size=100, max_batches=10) def post(events): @@ -138,7 +74,9 @@ def post(events): url = _get_ingest_url() data = frappe.as_json({"events": events}) resp = session.post(url, data=data, timeout=15) - return 200 <= resp.status_code < 300 + if not (200 <= resp.status_code < 300): + frappe.logger("pulse").error(f"pulse-client - post failed: {resp.status_code} {resp.text}") + return resp def _create_session(): @@ -164,6 +102,102 @@ def _get_ingest_url(): return f"{host}/{endpoint}" +class EventQueue: + def __init__(self): + self.queue = "pulse-client:events" + self.queue_size = 10000 + self.ratelimit_prefix = "pulse-client:last_sent:" + + @property + def length(self): + return frappe.cache.llen(self.queue) + + def add(self, event, interval=None): + if self._is_ratelimited(event, interval): + return + + self._queue_event(event) + self._update_ratelimit(event, interval) + + def _is_ratelimited(self, event, interval): + if not interval: + return False + + interval_seconds = parse_interval(interval) + event_key = self._get_event_key(event) + last_sent_key = f"{self.ratelimit_prefix}{event_key}" + last_sent = frappe.cache.get_value(last_sent_key) + + if last_sent and time.monotonic() - float(last_sent) < interval_seconds: + return True + + return False + + def _get_event_key(self, event): + return f"{event.get('event_name')}:{event.get('site')}:{event.get('app')}:{event.get('user')}" + + def _update_ratelimit(self, event, interval): + if not interval: + return + event_key = self._get_event_key(event) + last_sent_key = f"{self.ratelimit_prefix}{event_key}" + frappe.cache.set_value(last_sent_key, time.monotonic()) + + def _queue_event(self, event): + frappe.cache.lpush(self.queue, frappe.as_json(event)) + frappe.cache.ltrim(self.queue, 0, self.queue_size - 1) + + def batch_process(self, fn, batch_size=100, max_batches=10): + for _ in range(max_batches): + events = self.collect(batch_size) + if not events: + break + try: + fn(events) + except Exception as e: + frappe.logger("pulse").error(f"pulse-client - batch_process failed: {e!s}") + + def collect(self, batch_size=100): + events = [] + for _ in range(batch_size): + event_json = frappe.cache.rpop(self.queue) + if not event_json: + break + data = self._decode_event(event_json) + if data: + events.append(data) + return events + + def _decode_event(self, event_json): + event_json = event_json.decode() + with suppress(JSONDecodeError): + return frappe.parse_json(event_json) + + def get_events(self, limit=20): + events = [] + for _ in range(limit): + event_json = frappe.cache.lindex(self.queue, _) + if not event_json: + break + data = self._decode_event(event_json) + if data: + events.append(data) + return events + + def get_last_sent_events(self, limit=20): + events = [] + keys = frappe.cache.get_keys(f"{self.ratelimit_prefix}*")[:limit] + for key in keys: + last_sent = frappe.cache.get_value(key) + event_key = key.replace(self.ratelimit_prefix, "") + events.append( + { + "event_key": event_key, + "last_sent": last_sent, + } + ) + + @frappe.whitelist() def get_debug_info(fetch_events=None, fetch_rate_limited_events=None): frappe.only_for("System Manager") @@ -172,28 +206,15 @@ def get_debug_info(fetch_events=None, fetch_rate_limited_events=None): info.is_enabled = is_enabled() if info.is_enabled: - info.queued_event_count = queue_length() + eq = EventQueue() + info.queued_event_count = eq.length if fetch_events: - info.queued_events = [] limit = int(fetch_events) if str(fetch_events).isdigit() else 20 - for _ in range(min(limit, info.queued_event_count)): - event_json = frappe.cache.lindex("pulse-client:events", _) - data = decode_event(event_json) - if data: - info.queued_events.append(data) + info.queued_events = eq.get_events(limit) if fetch_rate_limited_events: - info.rate_limited_events = [] limit = int(fetch_rate_limited_events) if str(fetch_rate_limited_events).isdigit() else 20 - for key in frappe.cache.get_keys("pulse-client:last_sent:*")[:limit]: - last_sent = frappe.cache.get_value(key) - event_key = key.replace("pulse-client:last_sent:", "") - info.rate_limited_events.append( - { - "event_key": event_key, - "last_sent": last_sent, - } - ) + info.rate_limited_events = eq.get_last_sent_events(limit) return info From 23c8673c459e5b8a08393452aea726d3ae7fb7a4 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sun, 4 Jan 2026 13:09:51 +0530 Subject: [PATCH 031/401] refactor: implement retry logic --- frappe/utils/telemetry/pulse/client.py | 33 ++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/frappe/utils/telemetry/pulse/client.py b/frappe/utils/telemetry/pulse/client.py index 6341e8262a..948711da8e 100644 --- a/frappe/utils/telemetry/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -69,13 +69,14 @@ def send_queued_events(): def post(events): - # TODO: implement retry logic session = _create_session() url = _get_ingest_url() data = frappe.as_json({"events": events}) resp = session.post(url, data=data, timeout=15) if not (200 <= resp.status_code < 300): - frappe.logger("pulse").error(f"pulse-client - post failed: {resp.status_code} {resp.text}") + msg = f"pulse-client - post failed: {resp.status_code} {resp.text}" + frappe.logger("pulse").error(msg) + raise Exception(msg) return resp @@ -147,15 +148,30 @@ class EventQueue: frappe.cache.lpush(self.queue, frappe.as_json(event)) frappe.cache.ltrim(self.queue, 0, self.queue_size - 1) - def batch_process(self, fn, batch_size=100, max_batches=10): + def batch_process(self, fn, batch_size=100, max_batches=10, max_retries=3, backoff_seconds=1): + pending_events = None + retry_attempts = 0 + for _ in range(max_batches): - events = self.collect(batch_size) + events = pending_events or self.collect(batch_size) if not events: break + try: fn(events) + pending_events = None + retry_attempts = 0 except Exception as e: - frappe.logger("pulse").error(f"pulse-client - batch_process failed: {e!s}") + retry_attempts += 1 + if retry_attempts > max_retries: + # Tried enough times, re-queue pending events and exit. + frappe.logger("pulse").error(f"pulse-client - max retries reached: {e!s}") + self._requeue_events(events) + break + + pending_events = events + time.sleep(backoff_seconds * (2 ** (retry_attempts - 1))) + frappe.logger("pulse").error(f"pulse-client - retrying batch due to error: {e!s}") def collect(self, batch_size=100): events = [] @@ -168,6 +184,12 @@ class EventQueue: events.append(data) return events + def _requeue_events(self, events): + # Preserve original processing order (FIFO): we pop from right, so re-add in reverse. + for event in reversed(events): + frappe.cache.rpush(self.queue, frappe.as_json(event)) + frappe.cache.ltrim(self.queue, 0, self.queue_size - 1) + def _decode_event(self, event_json): event_json = event_json.decode() with suppress(JSONDecodeError): @@ -196,6 +218,7 @@ class EventQueue: "last_sent": last_sent, } ) + return events @frappe.whitelist() From e51ce12b90ff70d9b982703727d2bc6f5ea7b412 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sun, 4 Jan 2026 13:31:44 +0530 Subject: [PATCH 032/401] feat: add test cases --- .../telemetry/pulse/test_pulse_client.py | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 frappe/utils/telemetry/pulse/test_pulse_client.py diff --git a/frappe/utils/telemetry/pulse/test_pulse_client.py b/frappe/utils/telemetry/pulse/test_pulse_client.py new file mode 100644 index 0000000000..22d072b562 --- /dev/null +++ b/frappe/utils/telemetry/pulse/test_pulse_client.py @@ -0,0 +1,397 @@ +import time +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils.telemetry.pulse.client import EventQueue, capture, is_enabled +from frappe.utils.telemetry.pulse.utils import anonymize_user, parse_interval + + +class TestPulseClient(IntegrationTestCase): + def setUp(self): + super().setUp() + # Clear any existing events from queue + eq = EventQueue() + while eq.length > 0: + eq.collect(batch_size=1000) + frappe.cache.delete_keys("pulse-client:") + + def tearDown(self): + # Clean up after tests + eq = EventQueue() + while eq.length > 0: + eq.collect(batch_size=1000) + frappe.cache.delete_keys("pulse-client:") + super().tearDown() + + +class TestEventQueue(TestPulseClient): + def test_queue_operations(self): + """Test queue add, collect, and FIFO behavior""" + eq = EventQueue() + + # Add events + for i in range(10): + event = { + "event_name": f"test_event_{i}", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + eq.add(event) + + self.assertEqual(eq.length, 10) + + # Collect events (FIFO order) + events = eq.collect(batch_size=5) + self.assertEqual(len(events), 5) + self.assertEqual(eq.length, 5) + self.assertEqual(events[0]["event_name"], "test_event_0") + + def test_queue_size_limit(self): + """Test that queue respects size limit""" + eq = EventQueue() + queue_size = eq.queue_size + + # Add more events than the queue size + for i in range(queue_size + 100): + event = { + "event_name": f"test_event_{i}", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + eq.add(event) + + # Queue should not exceed max size + self.assertEqual(eq.length, queue_size) + + def test_requeue_events(self): + """Test requeueing events preserves order""" + eq = EventQueue() + + # Add events + event_names = ["event_1", "event_2", "event_3"] + for name in event_names: + event = { + "event_name": name, + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + eq.add(event) + + # Collect and requeue + events = eq.collect(batch_size=3) + eq._requeue_events(events) + + # Check order is preserved + requeued = eq.collect(batch_size=3) + for i, event in enumerate(requeued): + self.assertEqual(event["event_name"], event_names[i]) + + +class TestRateLimiting(TestPulseClient): + def test_ratelimit_basic(self): + """Test basic rate limiting functionality""" + eq = EventQueue() + + event = { + "event_name": "test_event", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + + # First event should be added + eq.add(event, interval="5s") + self.assertEqual(eq.length, 1) + + # Second event should be rate-limited + eq.add(event, interval="5s") + self.assertEqual(eq.length, 1) + + def test_ratelimit_different_events(self): + """Test that rate limiting is per-event""" + eq = EventQueue() + + event1 = { + "event_name": "event_1", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + + event2 = { + "event_name": "event_2", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + + # Both events should be added as they are different + eq.add(event1, interval="5s") + eq.add(event2, interval="5s") + self.assertEqual(eq.length, 2) + + def test_ratelimit_expiry(self): + """Test that rate limit expires after interval""" + eq = EventQueue() + + event = { + "event_name": "test_event", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + + # Add event with short interval + eq.add(event, interval="1s") + self.assertEqual(eq.length, 1) + + # Wait for interval to expire + time.sleep(1.1) + + # Event should be added again + eq.add(event, interval="1s") + self.assertEqual(eq.length, 2) + + +class TestBatchProcessing(TestPulseClient): + def test_batch_process_success(self): + """Test successful batch processing""" + eq = EventQueue() + processed = [] + + def process_fn(events): + processed.extend(events) + + # Add events + for i in range(15): + event = { + "event_name": f"test_event_{i}", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + eq.add(event) + + # Process in batches + eq.batch_process(process_fn, batch_size=10, max_batches=2) + + # All events should be processed + self.assertEqual(len(processed), 15) + self.assertEqual(eq.length, 0) + + def test_batch_process_with_failure_and_retry(self): + """Test batch processing with failure and retry""" + eq = EventQueue() + call_count = 0 + + def failing_fn(events): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise Exception("Temporary failure") + return True + + # Add events + for i in range(5): + event = { + "event_name": f"test_event_{i}", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + eq.add(event) + + # Process with retries + eq.batch_process(failing_fn, batch_size=10, max_retries=5, backoff_seconds=0.1) + + # Should succeed after retries + self.assertGreaterEqual(call_count, 3) + self.assertEqual(eq.length, 0) + + def test_batch_process_max_retries_exceeded(self): + """Test batch processing when max retries is exceeded""" + eq = EventQueue() + + def always_failing_fn(events): + raise Exception("Always fails") + + # Add events + for i in range(5): + event = { + "event_name": f"test_event_{i}", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + eq.add(event) + + # Process with limited retries + eq.batch_process(always_failing_fn, batch_size=10, max_retries=2, backoff_seconds=0.1) + + # Events should be requeued + self.assertEqual(eq.length, 5) + + +class TestCapture(TestPulseClient): + @patch("frappe.utils.telemetry.pulse.client.is_enabled") + def test_capture_when_disabled(self, mock_enabled): + """Test that capture does nothing when disabled""" + is_enabled.clear_cache() + mock_enabled.return_value = False + eq = EventQueue() + + capture("test_event", site="test.localhost") + + self.assertEqual(eq.length, 0) + + @patch("frappe.utils.telemetry.pulse.client.is_enabled") + def test_capture_basic(self, mock_enabled): + """Test basic event capture""" + is_enabled.clear_cache() + mock_enabled.return_value = True + eq = EventQueue() + + capture( + "test_event", + site="test.localhost", + app="frappe", + user="test@example.com", + properties={"key": "value"}, + ) + + self.assertEqual(eq.length, 1) + events = eq.collect(batch_size=1) + self.assertEqual(events[0]["event_name"], "test_event") + self.assertEqual(events[0]["properties"]["key"], "value") + + @patch("frappe.utils.telemetry.pulse.client.is_enabled") + def test_capture_anonymizes_user(self, mock_enabled): + """Test that user is anonymized""" + is_enabled.clear_cache() + mock_enabled.return_value = True + eq = EventQueue() + + test_user = "test@example.com" + capture("test_event", site="test.localhost", user=test_user) + + events = eq.collect(batch_size=1) + # User should be anonymized + self.assertNotEqual(events[0]["user"], test_user) + self.assertTrue(events[0]["user"].startswith("anon_")) + + +class TestUtils(TestPulseClient): + def test_parse_interval(self): + """Test parsing various interval formats""" + # Seconds + self.assertEqual(parse_interval(60), 60) + self.assertEqual(parse_interval("60"), 60) + + # Minutes, hours, days, weeks + self.assertEqual(parse_interval("1m"), 60) + self.assertEqual(parse_interval("1h"), 3600) + self.assertEqual(parse_interval("1d"), 86400) + self.assertEqual(parse_interval("1w"), 604800) + + # Invalid formats + with self.assertRaises(ValueError): + parse_interval("1x") + + def test_anonymize_user(self): + """Test user anonymization""" + user = "test@example.com" + anon_user = anonymize_user(user) + + # Should be anonymized and consistent + self.assertNotEqual(anon_user, user) + self.assertTrue(anon_user.startswith("anon_")) + self.assertEqual(anonymize_user(user), anon_user) + + # Standard users not anonymized + for standard_user in frappe.STANDARD_USERS: + self.assertEqual(anonymize_user(standard_user), standard_user) + + +class TestEventQueueDecoding(TestPulseClient): + def test_decode_valid_event(self): + """Test decoding valid event JSON""" + eq = EventQueue() + + event = { + "event_name": "test_event", + "captured_at": "2026-01-01T00:00:00", + "app": "frappe", + "user": "test@example.com", + "site": "test.localhost", + "properties": {}, + } + + # Add and retrieve + eq.add(event) + event_json = frappe.cache.rpop(eq.queue) + + decoded = eq._decode_event(event_json) + self.assertIsNotNone(decoded) + self.assertEqual(decoded["event_name"], "test_event") + + def test_decode_invalid_json(self): + """Test decoding invalid JSON""" + eq = EventQueue() + + # Invalid JSON should return None + decoded = eq._decode_event(b"invalid json{") + self.assertIsNone(decoded) + + +class TestEventKey(TestPulseClient): + def test_event_key_generation_and_uniqueness(self): + """Test event key generation and uniqueness for rate limiting""" + eq = EventQueue() + + event1 = { + "event_name": "event_1", + "app": "frappe", + "user": "user1@example.com", + "site": "test.localhost", + } + + event2 = { + "event_name": "event_2", + "app": "frappe", + "user": "user1@example.com", + "site": "test.localhost", + } + + # Test key composition + key1 = eq._get_event_key(event1) + self.assertIn("event_1", key1) + self.assertIn("test.localhost", key1) + self.assertIn("frappe", key1) + + # Test uniqueness + key2 = eq._get_event_key(event2) + self.assertNotEqual(key1, key2) From 43d71dd02b0344b239a01f93380035248298abee Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sun, 4 Jan 2026 13:34:27 +0530 Subject: [PATCH 033/401] fix: handle string input for bulk_capture events --- frappe/utils/telemetry/pulse/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/utils/telemetry/pulse/client.py b/frappe/utils/telemetry/pulse/client.py index 948711da8e..cd3a60f234 100644 --- a/frappe/utils/telemetry/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -48,6 +48,9 @@ def bulk_capture(events): if not is_enabled(): return + if isinstance(events, str): + events = frappe.parse_json(events) + for event in events: capture( event.get("event_name"), From 142367c9b7ca318489d379aa6cac88075352a948 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sun, 4 Jan 2026 13:40:16 +0530 Subject: [PATCH 034/401] fix: preserve old behaviour * is_enabled wasn't cached before --- frappe/utils/telemetry/posthog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/utils/telemetry/posthog.py b/frappe/utils/telemetry/posthog.py index 77c11896ee..edbe028036 100644 --- a/frappe/utils/telemetry/posthog.py +++ b/frappe/utils/telemetry/posthog.py @@ -10,7 +10,6 @@ POSTHOG_PROJECT_FIELD = "posthog_project_id" POSTHOG_HOST_FIELD = "posthog_host" -@site_cache() def is_enabled(): return bool( frappe.conf.get(POSTHOG_HOST_FIELD) From 9bd495e0ed003d9ca27b7d45abae0bc83f7ac616 Mon Sep 17 00:00:00 2001 From: Kerolles Fathy Date: Sun, 4 Jan 2026 10:45:33 +0200 Subject: [PATCH 035/401] fix(sidebar): correct indentation for sidebar child items (#35513) --- frappe/workspace_sidebar/integrations.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/workspace_sidebar/integrations.json b/frappe/workspace_sidebar/integrations.json index c3a279c87d..aa53fcd538 100644 --- a/frappe/workspace_sidebar/integrations.json +++ b/frappe/workspace_sidebar/integrations.json @@ -113,7 +113,7 @@ "type": "Link" }, { - "child": 0, + "child": 1, "collapsible": 1, "icon": "settings", "indent": 0, @@ -125,7 +125,7 @@ "type": "Link" }, { - "child": 0, + "child": 1, "collapsible": 1, "icon": "list", "indent": 0, @@ -137,7 +137,7 @@ "type": "Link" }, { - "child": 0, + "child": 1, "collapsible": 1, "icon": "list", "indent": 0, @@ -149,7 +149,7 @@ "type": "Link" } ], - "modified": "2025-12-18 17:22:26.558605", + "modified": "2025-12-29 23:46:47.024937", "modified_by": "Administrator", "module": "Integrations", "name": "Integrations", From 61679295aae60601e5d2feb5ef79a2735f3888d2 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sun, 4 Jan 2026 14:20:40 +0530 Subject: [PATCH 036/401] chore: move common utility functions to `frappe.utils` --- frappe/utils/__init__.py | 11 +++++++++++ frappe/utils/telemetry/pulse/app_heartbeat_event.py | 2 +- frappe/utils/telemetry/pulse/utils.py | 13 +------------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index b9d444a94d..279c2d6d1f 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -1189,3 +1189,14 @@ def create_folder(path, with_init=False): cached_property = functools.cached_property + + +def get_frappe_version() -> str: + return getattr(frappe, "__version__", "unknown") + + +def get_app_version(app_name: str) -> str: + try: + return frappe.get_attr(app_name + ".__version__") + except Exception: + return "0.0.1" diff --git a/frappe/utils/telemetry/pulse/app_heartbeat_event.py b/frappe/utils/telemetry/pulse/app_heartbeat_event.py index 8169084f49..6fa16b5c63 100644 --- a/frappe/utils/telemetry/pulse/app_heartbeat_event.py +++ b/frappe/utils/telemetry/pulse/app_heartbeat_event.py @@ -1,7 +1,7 @@ import frappe +from frappe.utils import get_app_version, get_frappe_version from .client import capture, is_enabled -from .utils import get_app_version, get_frappe_version def capture_app_heartbeat(app): diff --git a/frappe/utils/telemetry/pulse/utils.py b/frappe/utils/telemetry/pulse/utils.py index a680f1cd51..5d6924f45e 100644 --- a/frappe/utils/telemetry/pulse/utils.py +++ b/frappe/utils/telemetry/pulse/utils.py @@ -1,5 +1,5 @@ import hashlib -from datetime import UTC, datetime, timezone +from datetime import UTC, datetime import frappe @@ -78,20 +78,9 @@ def parse_interval(interval): return number * multipliers[unit] -def get_frappe_version() -> str: - return getattr(frappe, "__version__", "unknown") - - def utc_iso() -> str: return datetime.now(UTC).isoformat() -def get_app_version(app_name: str) -> str: - try: - return frappe.get_attr(app_name + ".__version__") - except Exception: - return "0.0.1" - - def ensure_http(url: str) -> str: return url if url.startswith(("http://", "https://")) else "https://" + url From 4db80d8fe25080b18f9926a585ff4c2231d8732e Mon Sep 17 00:00:00 2001 From: MochaMind Date: Sun, 4 Jan 2026 15:42:46 +0530 Subject: [PATCH 037/401] fix: Russian translations (#35637) --- frappe/locale/ru.po | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/frappe/locale/ru.po b/frappe/locale/ru.po index dd2a0151b8..7bed58bfa8 100644 --- a/frappe/locale/ru.po +++ b/frappe/locale/ru.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: developers@frappe.io\n" "POT-Creation-Date: 2025-12-21 09:35+0000\n" -"PO-Revision-Date: 2025-12-24 20:23\n" +"PO-Revision-Date: 2026-01-03 23:05\n" "Last-Translator: developers@frappe.io\n" "Language-Team: Russian\n" "MIME-Version: 1.0\n" @@ -133,7 +133,15 @@ msgid "0 - too guessable: risky password.\n" "3 - safely unguessable: moderate protection from offline slow-hash scenario.\n" "
\n" "4 - very unguessable: strong protection from offline slow-hash scenario." -msgstr "" +msgstr "0 - слишком легко угадывается: рискованный пароль.\n" +"
\n" +"1 - очень легко угадывается: защита от атак с ограничением скорости. \n" +"
\n" +"2 - относительно легко угадывается: защита от атак без ограничения скорости.\n" +"
\n" +"3 - практически невозможно угадать: умеренная защита от сценариев с медленным хешированием в офлайн-режиме.\n" +"
\n" +"4 - очень трудно угадать: надежная защита от сценариев с медленным хешированием в офлайн-режиме." #. Description of the 'Priority' (Int) field in DocType 'Web Page' #: frappe/website/doctype/web_page/web_page.json @@ -1466,7 +1474,7 @@ msgstr "Добавить новую вкладку" #: frappe/utils/password_strength.py:191 msgid "Add numbers or special characters." -msgstr "" +msgstr "Добавьте цифры или специальные символы." #: frappe/public/js/print_format_builder/PrintFormatSection.vue:125 msgid "Add page break" @@ -5217,7 +5225,7 @@ msgstr "Распространенные имена и фамилии легко #: frappe/utils/password_strength.py:190 msgid "Common words are easy to guess." -msgstr "" +msgstr "Общие слова легко угадать." #. Name of a DocType #. Option for the 'Communication Type' (Select) field in DocType @@ -5912,7 +5920,7 @@ msgstr "Создать новую Канбан доску" #: frappe/public/js/frappe/list/list_filter.js:101 msgid "Create Saved Filter" -msgstr "" +msgstr "Создать сохраненный фильтр" #: frappe/core/doctype/user/user.js:271 msgid "Create User Email" @@ -12025,7 +12033,7 @@ msgstr "Помощь HTML" #. Description of the 'Content' (Text Editor) field in DocType 'Note' #: frappe/desk/doctype/note/note.json msgid "Help: To link to another record in the system, use \"/desk/note/[Note Name]\" as the Link URL. (don't use \"http://\")" -msgstr "" +msgstr "Помощь: Для ссылки на другую запись в системе используйте \"/desk/note/[Имя заметки]\" в качестве ссылки (не используйте \"http://\")" #. Label of the helpful (Int) field in DocType 'Help Article' #: frappe/website/doctype/help_article/help_article.json @@ -17498,7 +17506,7 @@ msgstr "Не найден шаблон по пути: {0}" #: frappe/core/page/permission_manager/permission_manager.js:362 msgid "No user has the role {0}" -msgstr "" +msgstr "Нет пользователя с ролью {0}" #: frappe/public/js/frappe/form/controls/multiselect_list.js:276 msgid "No values to show" @@ -19124,7 +19132,7 @@ msgstr "Пароль не найден для {0} {1} {2}" #: frappe/core/doctype/user/user.py:1307 msgid "Password requirements not met" -msgstr "" +msgstr "Пароль не соответствует требованиям" #: frappe/core/doctype/user/user.py:1140 msgid "Password reset instructions have been sent to {}'s email" @@ -28580,7 +28588,7 @@ msgstr "Используйте TLS" #: frappe/utils/password_strength.py:191 msgid "Use a few uncommon words together." -msgstr "" +msgstr "Используйте несколько редких слов вместе." #: frappe/utils/password_strength.py:44 msgid "Use a few words, avoid common phrases." @@ -29334,7 +29342,7 @@ msgstr "Посмотреть веб-сайт" #: frappe/core/page/permission_manager/permission_manager.js:395 msgid "View all {0} users" -msgstr "" +msgstr "Просмотреть всех {0} пользователей" #: frappe/www/confirm_workflow_action.html:12 msgid "View document" @@ -29456,7 +29464,7 @@ msgstr "Предупреждение: Обновление счетчика мо #: frappe/core/doctype/doctype/doctype.py:458 msgid "Warning: Usage of 'format:' is discouraged." -msgstr "" +msgstr "Внимание: использование конструкции 'format:' не рекомендуется." #: frappe/website/doctype/help_article/templates/help_article.html:24 msgid "Was this article helpful?" @@ -30472,7 +30480,7 @@ msgstr "Вы можете задать только 3 пользовательс #: frappe/handler.py:184 msgid "You can only upload JPG, PNG, GIF, PDF, TXT, CSV or Microsoft documents." -msgstr "" +msgstr "Вы можете загружать только документы в форматах JPG, PNG, GIF, PDF, TXT, CSV или Microsoft." #: frappe/core/doctype/data_export/exporter.py:199 msgid "You can only upload upto 5000 records in one go. (may be less in some cases)" @@ -32285,7 +32293,7 @@ msgstr "{0} недель назад" #: frappe/core/page/permission_manager/permission_manager.js:378 msgid "{0} with the role {1}" -msgstr "" +msgstr "{0} с ролью {1}" #: frappe/public/js/frappe/utils/pretty_date.js:39 msgid "{0} y" From 1ddeb4041145046d4082a74ab73eaa2c3b363f04 Mon Sep 17 00:00:00 2001 From: KerollesFathy Date: Sun, 4 Jan 2026 16:09:21 +0000 Subject: [PATCH 038/401] fix(sidebar): prevent user avatar shift on collapse/expand --- frappe/public/scss/desk/sidebar.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index 65d6dcedc1..3a8c64e283 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -166,7 +166,7 @@ } .nav-item { - margin-left: 0px; + margin-left: -5px; } } From 3fa2658a38ed0e4349e12cde476d2f468185544e Mon Sep 17 00:00:00 2001 From: KerollesFathy Date: Sun, 4 Jan 2026 16:33:14 +0000 Subject: [PATCH 039/401] fix(link_selector): add margin above "More" button for better spacing --- frappe/public/js/frappe/form/link_selector.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/public/js/frappe/form/link_selector.js b/frappe/public/js/frappe/form/link_selector.js index 4069958a6b..74595efded 100644 --- a/frappe/public/js/frappe/form/link_selector.js +++ b/frappe/public/js/frappe/form/link_selector.js @@ -156,6 +156,7 @@ frappe.ui.form.LinkSelector = class LinkSelector { }); } + parent.append('
'); var more_btn = me.dialog.fields_dict.more.$wrapper; if (results.length < me.page_length) { more_btn.hide(); From 084caa89324ae9804fc76982549154db77ceb834 Mon Sep 17 00:00:00 2001 From: KerollesFathy Date: Sun, 4 Jan 2026 17:53:15 +0000 Subject: [PATCH 040/401] fix(desktop): improve edit button visibility and dark theme styles --- frappe/desk/page/desktop/desktop.css | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frappe/desk/page/desktop/desktop.css b/frappe/desk/page/desktop/desktop.css index 9749e9af25..7ae5c0818b 100644 --- a/frappe/desk/page/desktop/desktop.css +++ b/frappe/desk/page/desktop/desktop.css @@ -446,9 +446,20 @@ bottom: 4%; right: 4%; z-index: 100; - opacity: 0.1; + opacity: 0.5; } + .desktop-edit:hover{ opacity: 1; transition: opacity 0.3s; +} + +[data-theme="dark"] .desktop-edit{ + background-color: var(--surface-gray-3); + opacity: 1; +} + +[data-theme="dark"] .desktop-edit:hover{ + opacity: 0.8; + transition: opacity 0.3s; } \ No newline at end of file From 2f20bdee70b3ab6a2b990a3b734b5b08e9e1b51c Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Mon, 5 Jan 2026 11:42:04 +0530 Subject: [PATCH 041/401] test(importer): add test to validate null label fallback to fieldname --- .../core/doctype/data_import/test_importer.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index d10a162182..476ccc33aa 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -1,7 +1,7 @@ # Copyright (c) 2019, Frappe Technologies and Contributors # License: MIT. See LICENSE import frappe -from frappe.core.doctype.data_import.importer import Importer +from frappe.core.doctype.data_import.importer import Importer, build_fields_dict_for_column_matching from frappe.tests import IntegrationTestCase from frappe.tests.test_query_builder import db_type_is, unimplemented_for from frappe.utils import format_duration, getdate @@ -146,6 +146,22 @@ class TestImporter(IntegrationTestCase): self.assertEqual(updated_doc.table_field_1[0].child_description, "child description") self.assertEqual(updated_doc.table_field_1_again[0].child_title, "child title again") + def test_data_import_without_label(self): + """Test fallback to fieldname when label is not set for a table.""" + + meta = frappe.get_meta(doctype_name) + table_field = meta.get_field("table_field_1") + original_label = table_field.label + table_field.label = None + fields_dict = build_fields_dict_for_column_matching(doctype_name) + expected_key = "Child Title (table_field_1)" + self.assertIn( + expected_key, fields_dict, f"Fallback failed: '{expected_key}' not found in mapping dict" + ) + expected_id_key = "ID (table_field_1)" + self.assertIn(expected_id_key, fields_dict, "ID fallback failed") + table_field.label = original_label # maintain sanity in test env + def get_importer(self, doctype, import_file, update=False, use_sniffer=False): data_import = frappe.new_doc("Data Import") data_import.import_type = "Insert New Records" if not update else "Update Existing Records" From f8443020ca8e314c718f3003e9ebe4ee9f5b948d Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Mon, 5 Jan 2026 12:11:16 +0530 Subject: [PATCH 042/401] fix(error): don't return in a finally block (PEP-765) (#35610) https://docs.python.org/3/whatsnew/3.14.html#pep-765-control-flow-in-finally-blocks Signed-off-by: Akhil Narang --- frappe/utils/error.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/utils/error.py b/frappe/utils/error.py index deed1deb34..3a424eff5d 100644 --- a/frappe/utils/error.py +++ b/frappe/utils/error.py @@ -109,9 +109,10 @@ def get_error_metadata() -> str: metadata["form_dict"] = sanitized_dict(frappe.form_dict) metadata["user"] = getattr(frappe.session, "user", "Unidentified") - finally: + except Exception: # We don't want to bother with exception handling *while* gathering some error's metadata - return frappe.as_json(metadata) # noqa: B012 + pass + return frappe.as_json(metadata) def log_error_snapshot(exception: Exception): From 8659e304e6e1a035918fa3f9fd6382ba7fed4a5f Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Mon, 5 Jan 2026 13:17:44 +0530 Subject: [PATCH 043/401] fix(Navbar): render text if title is html --- frappe/public/js/frappe/views/breadcrumbs.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/public/js/frappe/views/breadcrumbs.js b/frappe/public/js/frappe/views/breadcrumbs.js index af4345e5f4..5b45553daf 100644 --- a/frappe/public/js/frappe/views/breadcrumbs.js +++ b/frappe/public/js/frappe/views/breadcrumbs.js @@ -226,6 +226,9 @@ frappe.breadcrumbs = { } else { let title = frappe.model.get_doc_title(doc); docname_title = title || doc.name; + if (frappe.utils.is_html(docname_title)) { + docname_title = $(docname_title).text(); + } } this.append_breadcrumb_element(form_route, docname_title, "title-text-form"); From a9fef72d4eaa9511f53d7a3e79a7fc5a0e05cd7f Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 5 Jan 2026 13:23:35 +0530 Subject: [PATCH 044/401] fix: add compatibility shim for old `frappe.pulse.utils` imports --- frappe/pulse/__init__.py | 0 frappe/pulse/utils.py | 5 +++++ 2 files changed, 5 insertions(+) create mode 100644 frappe/pulse/__init__.py create mode 100644 frappe/pulse/utils.py diff --git a/frappe/pulse/__init__.py b/frappe/pulse/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/pulse/utils.py b/frappe/pulse/utils.py new file mode 100644 index 0000000000..b2523b2880 --- /dev/null +++ b/frappe/pulse/utils.py @@ -0,0 +1,5 @@ +"""Compatibility shim: keep old `frappe.pulse.utils` imports working.""" + +from frappe.utils import get_app_version, get_frappe_version + +__all__ = ["get_app_version", "get_frappe_version"] From 922c1b812d29ca73b9d64f58f511dee386160516 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 5 Jan 2026 13:44:32 +0530 Subject: [PATCH 045/401] fix(document naming): customer parser should be checked before anything else (#35586) --- frappe/model/naming.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/model/naming.py b/frappe/model/naming.py index a74f5a5b4d..c7caefd9d3 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -359,6 +359,8 @@ def parse_naming_series( digits = len(e) part = number_generator(name, digits) series_set = True + elif method := has_custom_parser(e): + part = frappe.get_attr(method[0])(doc, e) elif e == "YY": part = today.strftime("%y") elif e == "MM": @@ -376,8 +378,6 @@ def parse_naming_series( elif doc and (e.startswith("{") or doc.get(e, _sentinel) is not _sentinel): e = e.replace("{", "").replace("}", "") part = doc.get(e) - elif method := has_custom_parser(e): - part = frappe.get_attr(method[0])(doc, e) else: part = e From 6861d34e20144210888703767e812868a60e53ff Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Mon, 5 Jan 2026 15:31:17 +0530 Subject: [PATCH 046/401] fix: run after commit only if queue available --- frappe/email/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index c73ae65f04..44bb362f8d 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -243,6 +243,6 @@ def sendmail( # build email queue and send the email if send_now is True. q = builder.process(send_now=False) - if now: + if now and q: frappe.db.after_commit.add(q.send) return q From f60271a761126afcc0fa739f46c51d1e24c5eaca Mon Sep 17 00:00:00 2001 From: elshafei-developer Date: Mon, 5 Jan 2026 10:38:22 +0000 Subject: [PATCH 047/401] fix: add missing translation for "Next Actions" label --- frappe/core/doctype/success_action/success_action.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/success_action/success_action.js b/frappe/core/doctype/success_action/success_action.js index 5951be3fc0..84220ba744 100644 --- a/frappe/core/doctype/success_action/success_action.js +++ b/frappe/core/doctype/success_action/success_action.js @@ -41,7 +41,7 @@ frappe.ui.form.on("Success Action", { frm.action_multicheck = frappe.ui.form.make_control({ parent: next_actions_wrapper, df: { - label: "Next Actions", + label: __("Next Actions"), fieldname: "next_actions_multicheck", fieldtype: "MultiCheck", options: action_multicheck_options, From fb69f61196f8507181c7160b0c330509358b03d0 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Mon, 5 Jan 2026 16:26:50 +0530 Subject: [PATCH 048/401] fix(navbar): don't show copy on new doc --- frappe/public/js/frappe/views/breadcrumbs.js | 27 ++++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/frappe/public/js/frappe/views/breadcrumbs.js b/frappe/public/js/frappe/views/breadcrumbs.js index 5b45553daf..6ce9055698 100644 --- a/frappe/public/js/frappe/views/breadcrumbs.js +++ b/frappe/public/js/frappe/views/breadcrumbs.js @@ -221,8 +221,10 @@ frappe.breadcrumbs = { let form_route = `/desk/${frappe.router.slug(doctype)}/${encodeURIComponent(docname)}`; let docname_title; + let is_new_doc = false; if (docname.startsWith("new-" + doctype.toLowerCase().replace(/ /g, "-"))) { docname_title = __("New {0}", [__(doctype)]); + is_new_doc = true; } else { let title = frappe.model.get_doc_title(doc); docname_title = title || doc.name; @@ -239,16 +241,19 @@ frappe.breadcrumbs = { last_crumb.addClass("ellipsis"); last_crumb.find("a").addClass("ellipsis"); } - last_crumb.css("cursor", "copy"); - last_crumb.click((event) => { - event.stopImmediatePropagation(); - frappe.utils.copy_to_clipboard(doc.name); - }); - last_crumb.attr("title", __("Click to copy name")); - last_crumb.tooltip({ - delay: { show: 100, hide: 100 }, - trigger: "hover", - }); + + if (!is_new_doc) { + last_crumb.css("cursor", "copy"); + last_crumb.click((event) => { + event.stopImmediatePropagation(); + frappe.utils.copy_to_clipboard(doc.name); + }); + last_crumb.attr("title", __("Click to copy name")); + last_crumb.tooltip({ + delay: { show: 100, hide: 100 }, + trigger: "hover", + }); + } } }, @@ -276,7 +281,7 @@ frappe.breadcrumbs = { }, clear() { - this.$breadcrumbs = $(".navbar-breadcrumbs").empty(); + this.$breadcrumbs = $($(".navbar-breadcrumbs")[0]).empty(); this.append_breadcrumb_element("/desk", frappe.utils.icon("monitor")); }, From 261e78e4922f2a73480fcdad7a8de9344c2bf4e2 Mon Sep 17 00:00:00 2001 From: "ili.ad" <108145573+ili-ad@users.noreply.github.com> Date: Mon, 5 Jan 2026 06:15:41 -0500 Subject: [PATCH 049/401] fix(postgres): drop_index_if_exists uses DROP INDEX IF EXISTS (#35636) * fix(postgres): drop_index_if_exists uses DROP INDEX IF EXISTS * fix(linting): apply pre-commit to code --------- Co-authored-by: Matt Howard Co-authored-by: AarDG10 --- frappe/database/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frappe/database/utils.py b/frappe/database/utils.py index 3165046faf..0247d9fce4 100644 --- a/frappe/database/utils.py +++ b/frappe/database/utils.py @@ -193,7 +193,12 @@ def drop_index_if_exists(table: str, index: str): return try: - frappe.db.sql_ddl(f"ALTER TABLE `{table}` DROP INDEX `{index}`") + if frappe.db.db_type == "postgres": + # Postgres drops indexes with DROP INDEX, not ALTER TABLE ... DROP INDEX + safe_index = index.replace('"', '""') + frappe.db.sql_ddl(f'DROP INDEX IF EXISTS "{safe_index}"') + else: + frappe.db.sql_ddl(f"ALTER TABLE `{table}` DROP INDEX `{index}`") except Exception as e: frappe.log_error("Failed to drop index") click.secho(f"x Failed to drop index {index} from {table}\n {e!s}", fg="red") From de9c274f39524992f4c5f2ebddbed796dfb21dc8 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 5 Jan 2026 17:01:47 +0530 Subject: [PATCH 050/401] fix: flaky gunicorn test --- frappe/commands/test_commands.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/commands/test_commands.py b/frappe/commands/test_commands.py index d3498d8842..085351173d 100644 --- a/frappe/commands/test_commands.py +++ b/frappe/commands/test_commands.py @@ -1108,13 +1108,15 @@ class TestGunicornWorker(IntegrationTestCase): process = psutil.Process(self.handle.pid) return sum(c.cpu_percent(1.0) for c in process.children(True)) + process.cpu_percent(1.0) + usage_threshold = 10 + self.spawn_gunicorn(["--threads=2"]) - self.assertLessEqual(get_total_usage(), 3) + self.assertLessEqual(get_total_usage(), usage_threshold) # Wake up at least one thread, go idle and check again path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping" self.assertEqual(requests.get(path).status_code, 200) - self.assertLessEqual(get_total_usage(), 3) + self.assertLessEqual(get_total_usage(), usage_threshold) class TestRQWorker(IntegrationTestCase): From e7ffb2b3c8ad905cedffa42229be10867be0c2c6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 5 Jan 2026 17:05:43 +0530 Subject: [PATCH 051/401] feat: add bulk_update and bulk_delete endpoints to /api/v2 --- frappe/api/v2.py | 204 +++++++++++++++++++++++++++++ frappe/tests/test_api_v2.py | 253 ++++++++++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+) diff --git a/frappe/api/v2.py b/frappe/api/v2.py index 88bfe86527..e0d944fc87 100644 --- a/frappe/api/v2.py +++ b/frappe/api/v2.py @@ -235,6 +235,206 @@ def execute_doc_method(doctype: str, name: str, method: str | None = None): return result +def bulk_delete_docs(doctype: str): + """Bulk delete multiple documents of the same doctype. + + Request body should contain: + names: List of document names to delete + + Returns: + deleted: List of successfully deleted document names + failed: List of failed deletions with error messages + total: Total number of documents attempted + success_count: Number of successful deletions + failure_count: Number of failed deletions + """ + data = frappe.form_dict + names = frappe.parse_json(data.get("names", "[]")) + + if not isinstance(names, list): + frappe.throw(_("'names' must be an array")) + + deleted = [] + failed = [] + + for name in names: + try: + frappe.delete_doc(doctype, name, ignore_missing=False) + deleted.append(name) + except Exception as e: + failed.append({"name": name, "error": str(e)}) + + return { + "deleted": deleted, + "failed": failed, + "total": len(names), + "success_count": len(deleted), + "failure_count": len(failed), + } + + +def bulk_delete(): + """Bulk delete documents across multiple doctypes. + + Request body should contain: + documents: List of {"doctype": str, "name": str} objects + + Returns: + deleted: List of successfully deleted documents + failed: List of failed deletions with error messages + total: Total number of documents attempted + success_count: Number of successful deletions + failure_count: Number of failed deletions + """ + data = frappe.form_dict + documents = frappe.parse_json(data.get("documents", "[]")) + + if not isinstance(documents, list): + frappe.throw(_("Request body must contain 'documents' as an array")) + + deleted = [] + failed = [] + + for item in documents: + doctype = None + name = None + try: + if not isinstance(item, dict): + raise ValueError(_("Each document must be a dictionary with 'doctype' and 'name' keys")) + + doctype = item.get("doctype") + name = item.get("name") + + if not doctype or not name: + raise ValueError(_("Both 'doctype' and 'name' are required")) + + frappe.delete_doc(doctype, name, ignore_missing=False) + deleted.append({"doctype": doctype, "name": name}) + except Exception as e: + failed.append({"doctype": doctype, "name": name, "error": str(e)}) + + return { + "deleted": deleted, + "failed": failed, + "total": len(documents), + "success_count": len(deleted), + "failure_count": len(failed), + } + + +def bulk_update_docs(doctype: str): + """Bulk update multiple documents of the same doctype. + + Request body should contain: + updates: List of {"name": str, ...fields} objects where each object contains + the document name and the fields to update + + Returns: + updated: List of successfully updated document names + failed: List of failed updates with error messages + total: Total number of documents attempted + success_count: Number of successful updates + failure_count: Number of failed updates + """ + data = frappe.form_dict + updates = frappe.parse_json(data.get("updates", "[]")) + + if not isinstance(updates, list): + frappe.throw(_("'updates' must be an array")) + + updated = [] + failed = [] + + for item in updates: + name = None + try: + if not isinstance(item, dict): + raise ValueError(_("Each update must be a dictionary with 'name' and field values")) + + name = item.get("name") + if not name: + raise ValueError(_("'name' is required")) + + doc = frappe.get_doc(doctype, name, for_update=True) + item_copy = item.copy() + item_copy.pop("name") + item_copy.pop("flags", None) + + doc.update(item_copy) + doc.save() + + updated.append(name) + except Exception as e: + failed.append({"name": name, "error": str(e)}) + + return { + "updated": updated, + "failed": failed, + "total": len(updates), + "success_count": len(updated), + "failure_count": len(failed), + } + + +def bulk_update(): + """Bulk update documents across multiple doctypes. + + Request body should contain: + documents: List of {"doctype": str, "name": str, ...fields} objects + + Returns: + updated: List of successfully updated documents + failed: List of failed updates with error messages + total: Total number of documents attempted + success_count: Number of successful updates + failure_count: Number of failed updates + """ + data = frappe.form_dict + documents = frappe.parse_json(data.get("documents", "[]")) + + if not isinstance(documents, list): + frappe.throw(_("Request body must contain 'documents' as an array")) + + updated = [] + failed = [] + + for item in documents: + doctype = None + name = None + try: + if not isinstance(item, dict): + raise ValueError( + _("Each document must be a dictionary with 'doctype', 'name', and field values") + ) + + doctype = item.get("doctype") + name = item.get("name") + + if not doctype or not name: + raise ValueError(_("Both 'doctype' and 'name' are required")) + + doc = frappe.get_doc(doctype, name, for_update=True) + item_copy = item.copy() + item_copy.pop("doctype") + item_copy.pop("name") + item_copy.pop("flags", None) + + doc.update(item_copy) + doc.save() + + updated.append({"doctype": doctype, "name": name}) + except Exception as e: + failed.append({"doctype": doctype, "name": name, "error": str(e)}) + + return { + "updated": updated, + "failed": failed, + "total": len(documents), + "success_count": len(updated), + "failure_count": len(failed), + } + + def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None): """run a whitelisted controller method on in-memory document. @@ -272,6 +472,8 @@ url_rules = [ Rule("/method/logout", endpoint=logout, methods=["POST"]), Rule("/method/ping", endpoint=frappe.ping), Rule("/method/upload_file", endpoint=upload_file, methods=["POST"]), + Rule("/method/bulk_delete", endpoint=bulk_delete, methods=["POST"]), + Rule("/method/bulk_update", endpoint=bulk_update, methods=["POST"]), Rule("/method/", endpoint=handle_rpc_call), Rule( "/method/run_doc_method", @@ -282,6 +484,8 @@ url_rules = [ # Document level APIs Rule("/document/", methods=["GET"], endpoint=document_list), Rule("/document/", methods=["POST"], endpoint=create_doc), + Rule("/document//bulk_delete", methods=["POST"], endpoint=bulk_delete_docs), + Rule("/document//bulk_update", methods=["POST"], endpoint=bulk_update_docs), Rule("/document///", methods=["GET"], endpoint=read_doc), Rule("/document///copy", methods=["GET"], endpoint=copy_doc), Rule("/document///", methods=["PATCH", "PUT"], endpoint=update_doc), diff --git a/frappe/tests/test_api_v2.py b/frappe/tests/test_api_v2.py index d763dd0566..5d82955e4d 100644 --- a/frappe/tests/test_api_v2.py +++ b/frappe/tests/test_api_v2.py @@ -265,6 +265,259 @@ class TestMethodAPIV2(FrappeAPITestCase): self.assertEqual(response["data"]["content"], comment_txt) +class TestBulkOperationsV2(FrappeAPITestCase): + """Test bulk delete and bulk update endpoints""" + + version = "v2" + DOCTYPE = "ToDo" + GENERATED_DOCUMENTS: typing.ClassVar[list] = [] + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create test documents + for i in range(10): + doc = frappe.get_doc({"doctype": "ToDo", "description": f"Test bulk operations {i}"}).insert() + cls.GENERATED_DOCUMENTS.append(doc.name) + frappe.db.commit() + + @classmethod + def tearDownClass(cls): + frappe.db.commit() + for name in cls.GENERATED_DOCUMENTS: + frappe.delete_doc_if_exists(cls.DOCTYPE, name) + frappe.db.commit() + + def setUp(self) -> None: + self.post(self.method("login"), {"sid": self.sid}) + return super().setUp() + + def test_bulk_delete_docs_single_doctype(self): + # Create docs to delete + doc1 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete 1"}).insert() + doc2 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete 2"}).insert() + frappe.db.commit() + + # Bulk delete + response = self.post( + self.resource(self.DOCTYPE, "bulk_delete"), + {"names": frappe.as_json([doc1.name, doc2.name]), "sid": self.sid}, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 2) + self.assertEqual(data["failure_count"], 0) + self.assertIn(doc1.name, data["deleted"]) + self.assertIn(doc2.name, data["deleted"]) + + # Verify deletion + self.assertFalse(frappe.db.exists(self.DOCTYPE, doc1.name)) + self.assertFalse(frappe.db.exists(self.DOCTYPE, doc2.name)) + + def test_bulk_delete_docs_partial_failure(self): + # Create one valid doc + doc = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete"}).insert() + frappe.db.commit() + + # Try to delete valid and non-existent doc + non_existent = "non-existent-todo" + response = self.post( + self.resource(self.DOCTYPE, "bulk_delete"), + {"names": frappe.as_json([doc.name, non_existent]), "sid": self.sid}, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 1) + self.assertEqual(data["failure_count"], 1) + self.assertIn(doc.name, data["deleted"]) + self.assertEqual(len(data["failed"]), 1) + self.assertEqual(data["failed"][0]["name"], non_existent) + + def test_bulk_delete_cross_doctype(self): + # Create docs of different types + todo = frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert() + note = frappe.get_doc({"doctype": "Note", "title": "Test Note", "content": "Test"}).insert() + frappe.db.commit() + + # Bulk delete across doctypes + response = self.post( + self.method("bulk_delete"), + { + "documents": frappe.as_json( + [ + {"doctype": "ToDo", "name": todo.name}, + {"doctype": "Note", "name": note.name}, + ] + ), + "sid": self.sid, + }, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 2) + self.assertEqual(data["failure_count"], 0) + + # Verify deletion + self.assertFalse(frappe.db.exists("ToDo", todo.name)) + self.assertFalse(frappe.db.exists("Note", note.name)) + + def test_bulk_delete_invalid_format(self): + # Test with invalid format (not a list) + response = self.post( + self.method("bulk_delete"), + {"documents": frappe.as_json({"doctype": "ToDo", "name": "test"}), "sid": self.sid}, + ) + self.assertEqual(response.status_code, 417) + + # Test with invalid document format (not dict) + response = self.post( + self.method("bulk_delete"), + {"documents": frappe.as_json(["invalid-item"]), "sid": self.sid}, + ) + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["failure_count"], 1) + + def test_bulk_update_docs_single_doctype(self): + # Create fresh docs for this test + doc1 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original 1"}).insert() + doc2 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original 2"}).insert() + frappe.db.commit() + + try: + # Bulk update + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + { + "updates": frappe.as_json( + [ + {"name": doc1.name, "description": "Updated description 1", "priority": "High"}, + {"name": doc2.name, "description": "Updated description 2", "priority": "Low"}, + ] + ), + "sid": self.sid, + }, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 2) + self.assertEqual(data["failure_count"], 0) + self.assertIn(doc1.name, data["updated"]) + self.assertIn(doc2.name, data["updated"]) + + # Verify updates + updated_doc1 = frappe.get_doc(self.DOCTYPE, doc1.name) + updated_doc2 = frappe.get_doc(self.DOCTYPE, doc2.name) + self.assertEqual(updated_doc1.description, "Updated description 1") + self.assertEqual(updated_doc1.priority, "High") + self.assertEqual(updated_doc2.description, "Updated description 2") + self.assertEqual(updated_doc2.priority, "Low") + finally: + frappe.delete_doc_if_exists(self.DOCTYPE, doc1.name) + frappe.delete_doc_if_exists(self.DOCTYPE, doc2.name) + frappe.db.commit() + + def test_bulk_update_cross_doctype(self): + # Create test documents + todo = frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert() + note = frappe.get_doc({"doctype": "Note", "title": "Test", "content": "Test"}).insert() + frappe.db.commit() + + try: + # Bulk update across doctypes + response = self.post( + self.method("bulk_update"), + { + "documents": frappe.as_json( + [ + {"doctype": "ToDo", "name": todo.name, "description": "Updated ToDo"}, + {"doctype": "Note", "name": note.name, "title": "Updated Note"}, + ] + ), + "sid": self.sid, + }, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 2) + self.assertEqual(data["failure_count"], 0) + + # Verify updates + updated_todo = frappe.get_doc("ToDo", todo.name) + updated_note = frappe.get_doc("Note", note.name) + self.assertEqual(updated_todo.description, "Updated ToDo") + self.assertEqual(updated_note.title, "Updated Note") + finally: + frappe.delete_doc_if_exists("ToDo", todo.name) + frappe.delete_doc_if_exists("Note", note.name) + frappe.db.commit() + + def test_bulk_update_partial_failure(self): + # Create a fresh doc for this test + doc = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original"}).insert() + frappe.db.commit() + valid_doc = doc.name + non_existent = "non-existent-todo" + + try: + # Try to update valid and non-existent doc + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + { + "updates": frappe.as_json( + [ + {"name": valid_doc, "description": "Updated"}, + {"name": non_existent, "description": "Should fail"}, + ] + ), + "sid": self.sid, + }, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 1) + self.assertEqual(data["failure_count"], 1) + self.assertIn(valid_doc, data["updated"]) + self.assertEqual(len(data["failed"]), 1) + self.assertEqual(data["failed"][0]["name"], non_existent) + + # Verify successful update + updated_doc = frappe.get_doc(self.DOCTYPE, valid_doc) + self.assertEqual(updated_doc.description, "Updated") + finally: + frappe.delete_doc_if_exists(self.DOCTYPE, valid_doc) + frappe.db.commit() + + def test_bulk_update_invalid_format(self): + # Test with invalid format (not a list) + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + {"updates": frappe.as_json({"name": "test", "description": "test"}), "sid": self.sid}, + ) + self.assertEqual(response.status_code, 417) + + # Test with missing name field + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + {"updates": frappe.as_json([{"description": "test"}]), "sid": self.sid}, + ) + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["failure_count"], 1) + + class TestDocTypeAPIV2(FrappeAPITestCase): version = "v2" From b5785972d1b43c7525ccc5f258e536895694d506 Mon Sep 17 00:00:00 2001 From: "El-Shafei H." Date: Mon, 5 Jan 2026 16:01:09 +0300 Subject: [PATCH 052/401] fix: check len(item) before replace "\\n" with "\n" (#35571) --- frappe/translate.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/translate.py b/frappe/translate.py index 4c5b95d06d..64c80e7591 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -200,8 +200,10 @@ def get_translation_dict_from_file(path, lang, app, throw=False) -> dict[str, st csv_content = read_csv_file(path) for item in csv_content: - item[0] = item[0].replace("\\n", "\n") - item[1] = item[1].replace("\\n", "\n") + if len(item) in [2, 3]: + item[0] = item[0].replace("\\n", "\n") + item[1] = item[1].replace("\\n", "\n") + if len(item) == 3 and item[2]: key = item[0] + ":" + item[2] translation_map[key] = strip(item[1]) From 80e158b5339db1b07cdedc148f58fa3e76d4e31f Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Mon, 5 Jan 2026 18:37:32 +0530 Subject: [PATCH 053/401] fix: temporarily bump RQ job memory test by 1mb (#35663) Signed-off-by: Akhil Narang --- frappe/core/doctype/rq_job/test_rq_job.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/rq_job/test_rq_job.py b/frappe/core/doctype/rq_job/test_rq_job.py index 5bd94b4c1d..d5fa91620c 100644 --- a/frappe/core/doctype/rq_job/test_rq_job.py +++ b/frappe/core/doctype/rq_job/test_rq_job.py @@ -116,7 +116,8 @@ class TestRQJob(IntegrationTestCase): frappe.enqueue(self.BG_JOB, sleep=1, queue=q) _, stderr = execute_in_shell( - "bench worker-pool --queue short,default --burst --num-workers=4", check_exit_code=True + "bench worker-pool --queue short,default --burst --num-workers=4", + check_exit_code=True, ) self.assertIn("quitting", cstr(stderr)) @@ -178,7 +179,7 @@ class TestRQJob(IntegrationTestCase): LAST_MEASURED_USAGE += 2 # Observed higher usage on 3.14. Temporarily raising the limit - LAST_MEASURED_USAGE += 5 + LAST_MEASURED_USAGE += 6 self.assertLessEqual(rss, LAST_MEASURED_USAGE * 1.05, msg) From 27f104bba657483d911cd31ef91ffff5eb51dc20 Mon Sep 17 00:00:00 2001 From: MeIchthys Date: Mon, 5 Jan 2026 13:08:30 +0000 Subject: [PATCH 054/401] fix: Don't `set_status` to 'Closed' when status is already "Closed" (#35621) Co-authored-by: mgieger --- frappe/desk/form/assign_to.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index 655d81a7ab..bcebd4db0e 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -155,7 +155,7 @@ def close_all_assignments(doctype, name, ignore_permissions=False): assignments = frappe.get_all( "ToDo", fields=["allocated_to", "name"], - filters=dict(reference_type=doctype, reference_name=name, status=("!=", "Cancelled")), + filters=dict(reference_type=doctype, reference_name=name, status=("not in", ["Cancelled", "Closed"])), ) if not assignments: return False From 9a774de21ed863b44f849aa3307c31222142d56b Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Mon, 5 Jan 2026 19:02:04 +0530 Subject: [PATCH 055/401] fix: add permission conditions to where clause instead of join --- frappe/database/query.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index f327e3f385..2f2fa5f749 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -1833,13 +1833,11 @@ class LinkTableField(DynamicTableField): table = frappe.qb.DocType(self.doctype) main_table = frappe.qb.DocType(self.parent_doctype) if not query.is_joined(table): - clause = table.name == getattr(main_table, self.link_fieldname) - + query = query.left_join(table).on(table.name == getattr(main_table, self.link_fieldname)) if engine and engine.apply_permissions: if condition := engine.get_permission_conditions(self.doctype, table): - clause &= condition + query = query.where(condition) - query = query.left_join(table).on(clause) return query From 27970539af3ba4d4cc5d3ca3593742de9abecfd5 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Mon, 5 Jan 2026 19:11:12 +0530 Subject: [PATCH 056/401] fix(oauth2): introspect_token requires `token` (#35647) https://datatracker.ietf.org/doc/html/rfc7662#section-2 Signed-off-by: Akhil Narang --- frappe/integrations/oauth2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 063bd2a3bc..de62014536 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -225,7 +225,7 @@ def get_openid_configuration(): @frappe.whitelist(allow_guest=True) -def introspect_token(token=None, token_type_hint=None): +def introspect_token(token: str, token_type_hint=None): if token_type_hint not in ["access_token", "refresh_token"]: token_type_hint = "access_token" try: From 1ac4b81930daaa81a4a48b95fc2028fda8e6d64f Mon Sep 17 00:00:00 2001 From: KerollesFathy Date: Mon, 5 Jan 2026 13:53:26 +0000 Subject: [PATCH 057/401] fix(sidebar): add hover state to user avatar --- frappe/public/scss/desk/sidebar.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index 3a8c64e283..80ba906962 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -168,6 +168,12 @@ .nav-item { margin-left: -5px; } + + .dropdown-navbar-user { + &:hover { + @include hover-mixin(); + } + } } // show placeholder so that main section remains static From dbcdb0cc48bcaa80dce19782a2137de1a41f1cdd Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 5 Jan 2026 20:02:46 +0530 Subject: [PATCH 058/401] feat: allow sidebar items to use filters --- .../workspace_sidebar/workspace_sidebar.js | 28 ++++++++++++++++++- .../workspace_sidebar_item.json | 8 +++++- .../js/frappe/ui/sidebar/sidebar_item.js | 11 ++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js index e83985f52d..7b25693691 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js @@ -27,10 +27,10 @@ frappe.ui.form.on("Workspace Sidebar Item", { let row = locals[cdt][cdn]; let grid = frm.fields_dict.items.grid; let link_to = row.link_to; + let row_obj = grid.get_grid_row(cdn); if (link_to) { frappe.model.with_doctype(link_to, function () { let meta = frappe.get_meta(link_to); - let row_obj = grid.get_grid_row(cdn); let field_obj = row_obj.get_field("navigate_to_tab"); let tab_fieldnames = meta.fields .filter((field) => field.fieldtype === "Tab Break") @@ -41,3 +41,29 @@ frappe.ui.form.on("Workspace Sidebar Item", { } }, }); + +frappe.ui.form.on("Workspace Sidebar Item", { + form_render(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + let grid = frm.fields_dict.items.grid; + let row_obj = grid.get_grid_row(cdn); + let link_to = row.link_to; + if (!row_obj) return; + grid.update_docfield_property("filters", "hidden", 1); + const field = row_obj.get_field("filter_area"); + if (!field) return; + let filter_group = new frappe.ui.FilterGroup({ + parent: $(field.wrapper), + doctype: link_to, + on_change: () => { + frm.dirty(); + let fieldname = "filters"; + let value = JSON.stringify(filter_group.get_filters()); + frappe.model.set_value(cdt, cdn, fieldname, value); + }, + }); + if (row.filters) { + filter_group.add_filters_to_filter_group(JSON.parse(row.filters)); + } + }, +}); diff --git a/frappe/desk/doctype/workspace_sidebar_item/workspace_sidebar_item.json b/frappe/desk/doctype/workspace_sidebar_item/workspace_sidebar_item.json index 335f83e1ad..1cb976a9a9 100644 --- a/frappe/desk/doctype/workspace_sidebar_item/workspace_sidebar_item.json +++ b/frappe/desk/doctype/workspace_sidebar_item/workspace_sidebar_item.json @@ -25,6 +25,7 @@ "column_break_jexf", "display_depends_on", "section_break_whjq", + "filter_area", "filters", "route_options" ], @@ -162,13 +163,18 @@ "fieldname": "navigate_to_tab", "fieldtype": "Autocomplete", "label": "Tab" + }, + { + "fieldname": "filter_area", + "fieldtype": "HTML", + "label": "Filter Area" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-12-29 17:11:16.069665", + "modified": "2026-01-05 17:51:09.868113", "modified_by": "Administrator", "module": "Desk", "name": "Workspace Sidebar Item", diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index cef51e7961..bc11eb3f54 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -48,11 +48,18 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { route_options: JSON.parse(this.item.route_options), }); } else { - path = frappe.utils.generate_route({ + let args = { type: this.item.link_type, name: this.item.link_to, tab: this.item.tab, - }); + }; + if (this.item.filters) { + let filters_json = frappe.utils.get_filter_as_json( + JSON.parse(this.item.filters) + ); + args.filters = filters_json; + } + path = frappe.utils.generate_route(args); } } if (path) { From 98548e1ec7a2f56b887ea800e831fb224fdb6ed8 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 5 Jan 2026 20:11:20 +0530 Subject: [PATCH 059/401] fix(ui): add label and spacing --- frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js index 7b25693691..d052b74091 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.js @@ -62,6 +62,11 @@ frappe.ui.form.on("Workspace Sidebar Item", { frappe.model.set_value(cdt, cdn, fieldname, value); }, }); + $(field.wrapper).find(".filter-area").css("margin-bottom", "10px"); + $(field.wrapper) + .find(".filter-area") + .prepend(""); + if (row.filters) { filter_group.add_filters_to_filter_group(JSON.parse(row.filters)); } From 678d7ab0f9ec63cc3e3c4df7b95f4a3835ea7c30 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Mon, 5 Jan 2026 22:42:28 +0530 Subject: [PATCH 060/401] fix(login): don't let button text stuck at "Verifying" if you get rate limited (#35671) Resolves #35402 Signed-off-by: Akhil Narang --- frappe/templates/includes/login/login.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frappe/templates/includes/login/login.js b/frappe/templates/includes/login/login.js index 7b1d102215..d593274836 100644 --- a/frappe/templates/includes/login/login.js +++ b/frappe/templates/includes/login/login.js @@ -215,12 +215,10 @@ login.login_handlers = (function () { }) || []).join('
') || default_message; } - if (message === default_message) { - login.set_invalid(message); - } else { + login.set_invalid(default_message); + if (message !== default_message) { login.reset_sections(false); } - }; } @@ -290,7 +288,8 @@ login.login_handlers = (function () { 401: get_error_handler({{ _("Invalid Login. Try again.") | tojson }}), 417: get_error_handler({{ _("Oops! Something went wrong.") | tojson }}), 404: get_error_handler({{ _("User does not exist.") | tojson }}), - 500: get_error_handler({{ _("Something went wrong.") | tojson }}) + 429: get_error_handler({{ _("Too many requests. Please try again later.") | tojson }}), + 500: get_error_handler({{ _("Something went wrong.") | tojson }}), }; return login_handlers; From 7c0f68d3d8615be735e7cfb8849a09889d1975b9 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Tue, 6 Jan 2026 01:02:56 +0530 Subject: [PATCH 061/401] fix(kanban): ensure that we don't try to access [0] of an empty list (#35680) Signed-off-by: Akhil Narang --- frappe/desk/doctype/kanban_board/kanban_board.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py index 7560837584..bf9e41d9d6 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.py +++ b/frappe/desk/doctype/kanban_board/kanban_board.py @@ -221,8 +221,8 @@ def quick_kanban_board(doctype, board_name, field_name, project=None): def get_order_for_column(board, colname): filters = [[board.reference_doctype, board.field_name, "=", colname]] - if board.filters: - filters.append(frappe.parse_json(board.filters)[0]) + if board.filters and (parsed_filters := frappe.parse_json(board.filters)): + filters.append(parsed_filters[0]) return frappe.as_json(frappe.get_list(board.reference_doctype, filters=filters, pluck="name")) From 497aa61ceb009c82d0eb43fa8ca36f1520b478d6 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Tue, 6 Jan 2026 01:18:32 +0530 Subject: [PATCH 062/401] fix: select element by class --- frappe/public/js/frappe/views/breadcrumbs.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/views/breadcrumbs.js b/frappe/public/js/frappe/views/breadcrumbs.js index 6ce9055698..fe54380ca8 100644 --- a/frappe/public/js/frappe/views/breadcrumbs.js +++ b/frappe/public/js/frappe/views/breadcrumbs.js @@ -235,7 +235,7 @@ frappe.breadcrumbs = { this.append_breadcrumb_element(form_route, docname_title, "title-text-form"); if (view === "form") { - let last_crumb = this.$breadcrumbs.find("li").last(); + let last_crumb = this.$breadcrumbs.find(".title-text-form").parent(); last_crumb.addClass("disabled"); if (frappe.is_mobile()) { last_crumb.addClass("ellipsis"); @@ -281,7 +281,7 @@ frappe.breadcrumbs = { }, clear() { - this.$breadcrumbs = $($(".navbar-breadcrumbs")[0]).empty(); + this.$breadcrumbs = $(".navbar-breadcrumbs").empty(); this.append_breadcrumb_element("/desk", frappe.utils.icon("monitor")); }, From 28b0112eb718d1ff966a61b1ca204ce28a0fc0fc Mon Sep 17 00:00:00 2001 From: MochaMind Date: Tue, 6 Jan 2026 01:27:34 +0530 Subject: [PATCH 063/401] fix: Persian translations (#35645) --- frappe/locale/fa.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/locale/fa.po b/frappe/locale/fa.po index 1a5f81a23d..b6aafdac6f 100644 --- a/frappe/locale/fa.po +++ b/frappe/locale/fa.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: developers@frappe.io\n" "POT-Creation-Date: 2025-12-21 09:35+0000\n" -"PO-Revision-Date: 2026-01-01 22:27\n" +"PO-Revision-Date: 2026-01-04 23:13\n" "Last-Translator: developers@frappe.io\n" "Language-Team: Persian\n" "MIME-Version: 1.0\n" @@ -5421,7 +5421,7 @@ msgstr "گزینه‌های تماس، مانند «پرسمان فروش، در #. Label of the contacts (Small Text) field in DocType 'OAuth Client' #: frappe/integrations/doctype/oauth_client/oauth_client.json msgid "Contacts" -msgstr "مخاطب" +msgstr "مخاطبین" #: frappe/utils/change_log.py:362 msgid "Contains {0} security fix" @@ -16366,7 +16366,7 @@ msgstr "موزیلا از :has() پشتیبانی نمی‌کند، بنابرا #: frappe/desk/page/setup_wizard/install_fixtures.py:43 msgid "Mr" -msgstr "" +msgstr "آقا" #: frappe/desk/page/setup_wizard/install_fixtures.py:47 msgid "Mrs" From 4df72b2cf0bb4c96e582b8331518a598087cae7c Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Tue, 6 Jan 2026 01:38:48 +0530 Subject: [PATCH 064/401] fix: remove icons from tab --- frappe/core/doctype/docfield/docfield.json | 9 +-------- frappe/public/js/frappe/form/tab.js | 5 ----- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index c8df7f3693..b2c1b0d262 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -76,7 +76,6 @@ "width", "max_height", "columns", - "icon", "column_break_22", "description", "documentation_url", @@ -626,12 +625,6 @@ "fieldtype": "Select", "label": "Button Color", "options": "\nDefault\nPrimary\nInfo\nSuccess\nWarning\nDanger" - }, - { - "depends_on": "eval: doc.fieldtype == \"Tab Break\"", - "fieldname": "icon", - "fieldtype": "Icon", - "label": "Icon" } ], "grid_page_length": 50, @@ -639,7 +632,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-12-23 14:16:30.951385", + "modified": "2026-01-06 01:37:29.723265", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/public/js/frappe/form/tab.js b/frappe/public/js/frappe/form/tab.js index fa4842c187..954e3dd64c 100644 --- a/frappe/public/js/frappe/form/tab.js +++ b/frappe/public/js/frappe/form/tab.js @@ -35,11 +35,6 @@ export default class Tab { type="button" role="tab" aria-controls="${id}"> - ${ - ICON_MAP[this.label] || this.df.icon - ? frappe.utils.icon(this.df.icon || ICON_MAP[this.label]) - : "" - } ${__(this.label, null, this.doctype)} From 17e49bef175230e563167233e3a9a5fed16f24be Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Tue, 6 Jan 2026 01:55:54 +0530 Subject: [PATCH 065/401] fix: remove awesomebar icons --- .../js/frappe/ui/toolbar/awesome_bar.js | 19 +++--- .../js/frappe/ui/toolbar/search_utils.js | 62 +++++-------------- frappe/public/js/frappe/utils/utils.js | 35 ++--------- frappe/public/scss/desk/navbar.scss | 2 +- 4 files changed, 29 insertions(+), 89 deletions(-) diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js index b42b5cb3ce..0caad8e303 100644 --- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js +++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js @@ -28,7 +28,7 @@ frappe.search.AwesomeBar = class AwesomeBar { }); let search_modal_body = ``); if (!item.url) { - item_wrapper.on("click", function () { + item_wrapper.on("click", function (event) { item.onClick && item.onClick(); if (!(item.items && item.items.length)) { me.opts.onItemClick && me.opts.onItemClick(me.opts.parent); me.hide(); + } else { + if (!me.current_menu) { + me.nested_menus.forEach((menu) => { + if (menu.parent.get(0) == this) { + me.current_menu = menu; + } + }); + me.current_menu.show(event); + } else { + if (me.current_menu.parent.get(0) == this) { + // this ensures toggling would work on nested item's parent + me.current_menu.hide(); + me.current_menu = null; + } else { + // this ensures the other nested item would close before opening the next one + me.current_menu.hide(); + me.nested_menus.forEach((menu) => { + if (menu.parent.get(0) == this) { + me.current_menu = menu; + } + }); + me.current_menu.show(); + } + } + + // debugger + // if(!current_menu.visible){ + // current_menu.show(event) + // } + // me.nested_menus.forEach(menu => { + // if(current_menu == menu) return; + // menu.hide() + // }) } - me.nested_menus.forEach((menu) => { - menu.hide(); - }); + // me.nested_menus.forEach((menu) => { + // menu.hide(); + // }); }); } else if (item.items) { $(); @@ -100,20 +164,21 @@ frappe.ui.menu = class ContextMenu { parent: item_wrapper, menu_items: item.items, nested: true, + parent_data: item, parent_menu: this.name, }); } - show(parent, event) { - // this.close_all_other_menu(); - this.make(); - const offset = $(parent).offset(); - const height = $(parent).outerHeight(); + show(event) { + this.make(); + const offset = $(this.parent).offset(); + const height = $(this.parent).outerHeight(); this.left_offset = 0; this.gap = 4; if (this.opts.nested && this.opts.parent_menu) { let top = - parent.getBoundingClientRect().bottom - parent.getBoundingClientRect().height; + this.parent.get(0).getBoundingClientRect().bottom - + this.parent.get(0).getBoundingClientRect().height; let dropdown = frappe.menu_map[this.opts.parent_menu].template; let width = dropdown.outerWidth(); let offset = $(dropdown).offset(); @@ -121,7 +186,7 @@ frappe.ui.menu = class ContextMenu { if (frappe.utils.is_rtl()) { left = left - width - this.gap; } else { - left = width + this.gap; + left = left + width + this.gap; } this.template.css({ display: "block", @@ -139,7 +204,7 @@ frappe.ui.menu = class ContextMenu { } if (this.open_on_left) { - this.left_offset = parent.getBoundingClientRect().width; + this.left_offset = this.parent.get(0).getBoundingClientRect().width; this.template.css({ left: offset.left - @@ -149,7 +214,7 @@ frappe.ui.menu = class ContextMenu { }); } - if (event) { + if (this.opts.right_click) { this.template.css({ left: `${event.clientX}px`, top: `${event.clientY}px`, @@ -205,31 +270,6 @@ frappe.ui.create_menu = function (opts) { let context_menu = new frappe.ui.menu(opts); frappe.menu_map[context_menu.name] = context_menu; - if (opts.right_click) { - $(opts.parent).on("contextmenu", function (event) { - event.preventDefault(); - event.stopPropagation(); - if (frappe.menu_map[context_menu.name] && frappe.menu_map[context_menu.name].visible) { - frappe.menu_map[context_menu.name].hide(); - opts.onHide && opts.onHide(this); - } else { - frappe.menu_map[context_menu.name].show(this, event); - opts.onShow && opts.onShow(this); - } - }); - } else { - $(opts.parent).on("click", function (event) { - event.preventDefault(); - event.stopPropagation(); - if (frappe.menu_map[context_menu.name].visible) { - frappe.menu_map[context_menu.name].hide(); - opts.onHide && opts.onHide(this); - } else { - frappe.menu_map[context_menu.name].show(this); - opts.onShow && opts.onShow(this); - } - }); - } $(document).on("click", function () { if (frappe.menu_map[context_menu.name].visible) { From 161b5cc9e3f17c37d76f46cbe812a371d39c25a3 Mon Sep 17 00:00:00 2001 From: sokumon Date: Tue, 6 Jan 2026 21:28:49 +0530 Subject: [PATCH 086/401] fix: show correct icon direction --- frappe/public/js/frappe/ui/menu.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/ui/menu.js b/frappe/public/js/frappe/ui/menu.js index f7f6f1e751..b759b62a4f 100644 --- a/frappe/public/js/frappe/ui/menu.js +++ b/frappe/public/js/frappe/ui/menu.js @@ -87,7 +87,7 @@ frappe.ui.menu = class ContextMenu { : item.icon_url ? `` : ""; - + let chevron_direction = frappe.utils.is_rtl() ? "left " : "right"; item_wrapper = $(` ${__(item.label)} From adb0690d98258dfa179d9918a125e6fb74145886 Mon Sep 17 00:00:00 2001 From: sokumon Date: Wed, 7 Jan 2026 01:02:46 +0530 Subject: [PATCH 087/401] fix(desktop): add tooltip on icon-title --- frappe/desk/page/desktop/desktop.js | 7 ++++++- frappe/public/js/frappe/ui/desktop_icon.html | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index 37379377d5..5923ca2c4f 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -595,8 +595,13 @@ class DesktopIconGrid { this.icons_html.push(icon_html); grid.append(icon_html); }); + this.setup_tooltip(); + } + setup_tooltip() { + $('[data-toggle="tooltip"]').tooltip({ + placement: "bottom", + }); } - setup_reordering(grid) { const me = this; this.hoverTarget = null; diff --git a/frappe/public/js/frappe/ui/desktop_icon.html b/frappe/public/js/frappe/ui/desktop_icon.html index b4916f53bc..4ffc1622cd 100644 --- a/frappe/public/js/frappe/ui/desktop_icon.html +++ b/frappe/public/js/frappe/ui/desktop_icon.html @@ -16,7 +16,7 @@ {% } %} {% if (!in_folder) { %}
-
{{ __(icon.label) }}
+
{{ __(icon.label) }}
{% } %} From 2dbcfc8d5bcdbf6b6c19159b9cfc4d3d8cd22845 Mon Sep 17 00:00:00 2001 From: sokumon Date: Wed, 7 Jan 2026 01:54:47 +0530 Subject: [PATCH 088/401] fix: flickering issue on attach image --- frappe/public/js/frappe/form/controls/attach_image.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/attach_image.js b/frappe/public/js/frappe/form/controls/attach_image.js index 21c17f9754..1fd548f493 100644 --- a/frappe/public/js/frappe/form/controls/attach_image.js +++ b/frappe/public/js/frappe/form/controls/attach_image.js @@ -3,9 +3,10 @@ frappe.ui.form.ControlAttachImage = class ControlAttachImage extends frappe.ui.f super.make_input(); let $file_link = this.$value.find(".attached-file-link"); + // Changing placement from top to bottom to avoid flickering. Fix with better solution $file_link.popover({ trigger: "hover", - placement: "top", + placement: "bottom", content: () => { return `
Date: Wed, 7 Jan 2026 03:37:35 +0530 Subject: [PATCH 089/401] fix: show desktop icons instead of workspaces --- .../js/frappe/ui/sidebar/sidebar_header.js | 43 +++++++------- frappe/public/js/frappe/utils/utils.js | 58 +++++++++++++++++++ 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_header.js b/frappe/public/js/frappe/ui/sidebar/sidebar_header.js index e013565d23..7e485948c9 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_header.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_header.js @@ -5,7 +5,7 @@ frappe.ui.SidebarHeader = class SidebarHeader { this.drop_down_expanded = false; this.title = this.sidebar.sidebar_title; const me = this; - this.sibling_workspaces = this.fetch_sibling_workspaces(); + this.sibling_workspaces = this.fetch_related_icons(); this.dropdown_items = [ { name: "workspaces", @@ -81,29 +81,32 @@ frappe.ui.SidebarHeader = class SidebarHeader { this.populate_dropdown_menu(); this.setup_select_options(); } - fetch_sibling_workspaces() { + + fetch_related_icons() { let sibling_workspaces = []; if (frappe.current_app) { - let workspaces = [...frappe.current_app.workspaces]; - workspaces.splice(workspaces.indexOf(this.title), 1); + let workspaces = [...frappe.boot.desktop_icons]; + workspaces.splice( + workspaces.indexOf(frappe.utils.get_desktop_icon_by_label(this.title)), + 1 + ); workspaces.forEach((w) => { - let item = { - name: w.toLowerCase(), - label: w, - url: frappe.utils.generate_route({ - type: "Workspace", - route: frappe.router.slug(w), - }), - }; - if (frappe.utils.get_desktop_icon(w, frappe.boot.desktop_icon_style)) { - item.icon_url = frappe.utils.get_desktop_icon( - w, - frappe.boot.desktop_icon_style - ); - } else { - item.icon_html = frappe.utils.desktop_icon(w, "gray", "sm"); + if (w.app && w.app == frappe.current_app.app_name) { + let item = { + name: w.label.toLowerCase(), + label: w.label, + url: frappe.utils.get_route_for_icon(w), + }; + if (frappe.utils.get_desktop_icon(w.label, frappe.boot.desktop_icon_style)) { + item.icon_url = frappe.utils.get_desktop_icon( + w.label, + frappe.boot.desktop_icon_style + ); + } else { + item.icon_html = frappe.utils.desktop_icon(w.label, "gray", "sm"); + } + sibling_workspaces.push(item); } - sibling_workspaces.push(item); }); return sibling_workspaces; } diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 4116607f42..09dea2006b 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1253,6 +1253,64 @@ Object.assign(frappe.utils, { }, image_path: "/assets/frappe/images/leaflet/", }, + get_route_for_icon(desktop_icon) { + let route; + if (!desktop_icon) return; + let item = {}; + if (desktop_icon.link_type == "External" && desktop_icon.link) { + route = window.location.origin + desktop_icon.link; + } else { + let sidebar = frappe.boot.workspace_sidebar_item[desktop_icon.label.toLowerCase()]; + if (desktop_icon.link_type == "Workspace Sidebar" && sidebar) { + let first_link = sidebar.items.find((i) => i.type == "Link"); + if (first_link) { + if (first_link.link_type === "Report") { + let args = { + type: first_link.link_type, + name: first_link.link_to, + }; + + if (first_link.report || !frappe.app.sidebar.editor.edit_mode) { + args.is_query_report = + first_link.report.report_type === "Query Report" || + first_link.report.report_type == "Script Report"; + args.report_ref_doctype = first_link.report.ref_doctype; + } + + route = frappe.utils.generate_route(args); + } else if (first_link.link_type == "Workspace") { + let workspaces = frappe.workspaces[frappe.router.slug(first_link.link_to)]; + if (workspaces) { + if (workspaces.public) { + route = "/desk/" + frappe.router.slug(first_link.link_to); + } else { + route = "/desk/private/" + frappe.router.slug(workspaces.title); + } + } + + if (first_link.route) { + route = first_link.route; + } + } else if (first_link.link_type === "URL") { + route = first_link.url; + } else if (first_link.link_type == "Page" && first_link.route_options) { + route = frappe.utils.generate_route({ + type: first_link.link_type, + name: first_link.link_to, + route_options: JSON.parse(first_link.route_options), + }); + } else { + route = frappe.utils.generate_route({ + type: first_link.link_type, + name: first_link.link_to, + tab: first_link.tab, + }); + } + } + } + } + return route; + }, desktop_icon(label, color, size) { let letter = label.charAt(0).toUpperCase(); let icon_size = size ? size : "md"; From 51911ef928948d00829a3b190a5961a0de035c61 Mon Sep 17 00:00:00 2001 From: sokumon Date: Wed, 7 Jan 2026 05:04:14 +0530 Subject: [PATCH 090/401] fix: add ability to show folders content in workspaces options --- .../js/frappe/ui/sidebar/sidebar_header.js | 78 ++++++++++++++----- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_header.js b/frappe/public/js/frappe/ui/sidebar/sidebar_header.js index 7e485948c9..ad55efddaf 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_header.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_header.js @@ -85,33 +85,73 @@ frappe.ui.SidebarHeader = class SidebarHeader { fetch_related_icons() { let sibling_workspaces = []; if (frappe.current_app) { - let workspaces = [...frappe.boot.desktop_icons]; - workspaces.splice( - workspaces.indexOf(frappe.utils.get_desktop_icon_by_label(this.title)), + let desktop_icons = [...frappe.boot.desktop_icons]; + desktop_icons.splice( + desktop_icons.indexOf(frappe.utils.get_desktop_icon_by_label(this.title)), 1 ); - workspaces.forEach((w) => { - if (w.app && w.app == frappe.current_app.app_name) { - let item = { - name: w.label.toLowerCase(), - label: w.label, - url: frappe.utils.get_route_for_icon(w), - }; - if (frappe.utils.get_desktop_icon(w.label, frappe.boot.desktop_icon_style)) { - item.icon_url = frappe.utils.get_desktop_icon( - w.label, - frappe.boot.desktop_icon_style - ); - } else { - item.icon_html = frappe.utils.desktop_icon(w.label, "gray", "sm"); - } - sibling_workspaces.push(item); + let { folder_map, sibling_icons } = this.build_folder_map(desktop_icons); + sibling_icons.forEach((icon) => { + if (folder_map[icon.parent_icon]) return; + let item = { + name: icon.label.toLowerCase(), + label: icon.label, + url: frappe.utils.get_route_for_icon(icon), + }; + if (icon.icon_type == "Folder") { + this.get_icon_for_menu_item(icon, item); + item.items = folder_map[item.label]; } + if (frappe.utils.get_desktop_icon(icon.label, frappe.boot.desktop_icon_style)) { + item.icon_url = frappe.utils.get_desktop_icon( + icon.label, + frappe.boot.desktop_icon_style + ); + } else { + item.icon_html = frappe.utils.desktop_icon(icon.label, "gray", "sm"); + } + sibling_workspaces.push(item); }); return sibling_workspaces; } } + get_icon_for_menu_item(icon, item) { + if (frappe.utils.get_desktop_icon(icon.label, frappe.boot.desktop_icon_style)) { + item.icon_url = frappe.utils.get_desktop_icon( + icon.label, + frappe.boot.desktop_icon_style + ); + } else { + item.icon_html = frappe.utils.desktop_icon(icon.label, "gray", "sm"); + } + } + build_folder_map(desktop_icons) { + const folder_map = {}; + const sibling_icons = []; + if (!frappe.current_app) return; + desktop_icons.forEach((icon) => { + if ( + icon.link_type != "External" && + icon.app == frappe.current_app.app_name && + !icon.hidden + ) { + if (icon.icon_type === "Folder" && !folder_map[icon.label]) { + folder_map[icon.label] = []; + } + if (icon.parent_icon) { + icon.url = frappe.utils.get_route_for_icon(icon); + if (folder_map[icon.parent_icon]) folder_map[icon.parent_icon].push(icon); + } + sibling_icons.push(icon); + } + }); + + return { + folder_map: folder_map, + sibling_icons: sibling_icons, + }; + } get_help_siblings() { const navbar_settings = frappe.boot.navbar_settings; let help_dropdown_items = []; From aa81cd5dca2556be33b0ac1623bf93bf052af3f9 Mon Sep 17 00:00:00 2001 From: sokumon Date: Wed, 7 Jan 2026 06:09:08 +0530 Subject: [PATCH 091/401] feat: search and open desktop icons --- .../public/js/frappe/ui/toolbar/awesome_bar.js | 8 ++++++-- .../public/js/frappe/ui/toolbar/search_utils.js | 16 ++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js index 0caad8e303..c5850990f7 100644 --- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js +++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js @@ -107,6 +107,10 @@ frappe.search.AwesomeBar = class AwesomeBar { ) ); } + if (d.type == "Desktop Icon") { + target = frappe.utils.get_route_for_icon(d.icon_data); + d.route = target; + } let html = `${__(d.label || d.value)}`; if (d.description && d.value !== d.description) { @@ -189,7 +193,7 @@ frappe.search.AwesomeBar = class AwesomeBar { if (event.ctrlKey || event.metaKey) { frappe.open_in_new_tab = true; } - if (item.route[0].startsWith("https://")) { + if (item.route && item.route[0].startsWith("https://")) { window.open(item.route[0], "_blank"); return; } @@ -278,7 +282,7 @@ frappe.search.AwesomeBar = class AwesomeBar { frappe.search.utils.get_doctypes(txt), frappe.search.utils.get_reports(txt), frappe.search.utils.get_pages(txt), - frappe.search.utils.get_workspaces(txt), + frappe.search.utils.get_desktop_icons(txt), frappe.search.utils.get_dashboards(txt), frappe.search.utils.get_recent_pages(txt || ""), frappe.search.utils.get_executables(txt), diff --git a/frappe/public/js/frappe/ui/toolbar/search_utils.js b/frappe/public/js/frappe/ui/toolbar/search_utils.js index fbd99f1663..2de07432a2 100644 --- a/frappe/public/js/frappe/ui/toolbar/search_utils.js +++ b/frappe/public/js/frappe/ui/toolbar/search_utils.js @@ -328,19 +328,19 @@ frappe.search.utils = { return out; }, - get_workspaces: function (keywords) { + get_desktop_icons: function (keywords) { var me = this; var out = []; - frappe.boot.allowed_workspaces.forEach(function (item) { - const search_result = me.fuzzy_search(keywords, item.name, true); + frappe.boot.desktop_icons.forEach(function (item) { + const search_result = me.fuzzy_search(keywords, item.label, true); var level = search_result.score; if (level > 0) { var ret = { - type: "Workspace", - label: __("Open {0}", [search_result.marked_string || __(item.name)]), - value: __("Open {0}", [__(item.name)]), + type: "Desktop Icon", + label: __("Open {0}", [search_result.marked_string || __(item.label)]), + value: __("Open {0}", [__(item.label)]), index: level, - route: [frappe.router.slug(item.name)], + icon_data: item, }; out.push(ret); @@ -568,7 +568,7 @@ frappe.search.utils = { results: sort_uniques(this.get_pages(keywords)), }, { - title: __("Workspace"), + title: __("Desktop Icon"), fetch_type: "Nav", results: sort_uniques(this.get_workspaces(keywords)), }, From 553d92a79d2d8dc1595be431132b2d98751fc280 Mon Sep 17 00:00:00 2001 From: sokumon Date: Wed, 7 Jan 2026 07:44:40 +0530 Subject: [PATCH 092/401] fix: close all nested items correctly --- frappe/public/js/frappe/ui/menu.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/menu.js b/frappe/public/js/frappe/ui/menu.js index b759b62a4f..b158deb367 100644 --- a/frappe/public/js/frappe/ui/menu.js +++ b/frappe/public/js/frappe/ui/menu.js @@ -149,7 +149,14 @@ frappe.ui.menu = class ContextMenu { } else if (item.items) { $(); } else { - $(item_wrapper).find("a").attr("href", item.url); + item_wrapper.on("click", function () { + me.nested_menus.forEach((menu) => { + menu.hide(); + }); + me.hide(); + me.opts.onHide && me.opts.onHide(me); + frappe.set_route(item.url); + }); } } item_wrapper.appendTo(this.template); From fedf828ea23b18900733de1096155b967504e56f Mon Sep 17 00:00:00 2001 From: sokumon Date: Wed, 7 Jan 2026 07:56:27 +0530 Subject: [PATCH 093/401] fix: show desktop icons in nested menu --- frappe/public/js/frappe/ui/menu.js | 6 +++--- .../js/frappe/ui/sidebar/sidebar_header.js | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/ui/menu.js b/frappe/public/js/frappe/ui/menu.js index b158deb367..f2d3b77788 100644 --- a/frappe/public/js/frappe/ui/menu.js +++ b/frappe/public/js/frappe/ui/menu.js @@ -80,12 +80,12 @@ frappe.ui.menu = class ContextMenu { `` ); } else { - const iconMarkup = item.icon_html + const iconMarkup = item.icon_url + ? `` + : item.icon_html ? item.icon_html : item.icon ? frappe.utils.icon(item.icon) - : item.icon_url - ? `` : ""; let chevron_direction = frappe.utils.is_rtl() ? "left " : "right"; item_wrapper = $(`
-
+
Date: Sun, 11 Jan 2026 15:08:04 +0000 Subject: [PATCH 172/401] refactor: show active filter label on btn when applying saved filter --- frappe/public/js/frappe/list/list_filter.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frappe/public/js/frappe/list/list_filter.js b/frappe/public/js/frappe/list/list_filter.js index 16c85c0428..ff6e40a99b 100644 --- a/frappe/public/js/frappe/list/list_filter.js +++ b/frappe/public/js/frappe/list/list_filter.js @@ -7,6 +7,7 @@ export default class ListFilter { Object.assign(this, arguments[0]); this.can_add_global = frappe.user.has_role(["System Manager", "Administrator"]); this.filters = []; + this.active_filter = null; this.refresh_list_filter(); } @@ -50,12 +51,17 @@ export default class ListFilter { apply_saved_filter(filter_name, filter_label) { this.list_view.filter_area.clear().then(() => { this.list_view.filter_area.add(this.get_filters_values(filter_name)); - this.list_view.page.add_inner_message( - __("Applied saved filter: {0}", [filter_label.bold()]) - ); + this.active_filter = filter_label; + this.update_active_filter_label(this.active_filter); }); } + update_active_filter_label(label) { + $(`.inner-group-button[data-label="${encodeURIComponent("Saved Filters")}"] button`) + .contents() + .first()[0].textContent = label; + } + bind_remove_filter(filter) { frappe.confirm( __("Are you sure you want to remove the {0} filter?", [filter.filter_name.bold()]), @@ -63,6 +69,7 @@ export default class ListFilter { const name = filter.name; const applied_filters = this.get_filters_values(name); this.remove_filter(name).then(() => this.refresh_list_filter()); + this.update_active_filter_label("Saved Filters"); this.list_view.filter_area.remove_filters(applied_filters); } ); @@ -89,7 +96,8 @@ export default class ListFilter { const $clear_item = this.filter_template(clear_filters, true); $clear_item.find(".filter-label").on("click", (e) => { this.list_view.filter_area.clear(); - $(".inner-page-message").remove(); + this.active_filter = null; + this.update_active_filter_label("Saved Filters"); }); $menu.append($clear_item); } From dfd5be5d1e997da1ea71f2f3f7ee89107dc4767b Mon Sep 17 00:00:00 2001 From: Pugazhendhi Velu Date: Sun, 11 Jan 2026 15:27:01 +0000 Subject: [PATCH 173/401] feat(data_exporter): add search input for field selection --- .../js/frappe/data_import/data_exporter.js | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/data_import/data_exporter.js b/frappe/public/js/frappe/data_import/data_exporter.js index 4ff41ab355..540af89179 100644 --- a/frappe/public/js/frappe/data_import/data_exporter.js +++ b/frappe/public/js/frappe/data_import/data_exporter.js @@ -91,12 +91,13 @@ frappe.data_import.DataExporter = class DataExporter { ], primary_action_label: __("Export"), primary_action: (values) => this.export_records(values), - on_page_show: () => this.select_mandatory(), + on_page_show: () => this.setup_on_page_show(), }); this.make_filter_area(); this.make_select_all_buttons(); this.update_record_count_message(); + this.setup_search_input(); this.dialog.show(); } @@ -303,6 +304,29 @@ frappe.data_import.DataExporter = class DataExporter { }; }); } + + setup_search_input() { + const $wrapper = this.dialog.get_field("select_all_buttons").$wrapper; + + // prevent duplicate search inputs + if (this.dialog.$wrapper.find(".filters-search").length) return; + + $wrapper.before(` + + `); + } + + setup_on_page_show() { + frappe.utils.setup_search(this.dialog.$body, ".unit-checkbox", ".label-area"); + this.select_mandatory(); + } }; export function get_columns_for_picker(doctype) { From 0ede81c92e33f5b27201158cdd4d09001e50314d Mon Sep 17 00:00:00 2001 From: Pugazhendhi Velu Date: Sun, 11 Jan 2026 15:27:27 +0000 Subject: [PATCH 174/401] refactor: enhance setup_search with empty state and grouped filtering --- frappe/public/js/frappe/utils/utils.js | 59 +++++++++++++++++++------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 7a68aa206f..20727e9577 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -979,25 +979,54 @@ Object.assign(frappe.utils, { const $search_input = $wrapper.find('[data-element="search"]').show(); $search_input.focus().val(""); const $elements = $wrapper.find(el_class).show(); + const $multichecks = $wrapper.find('[data-fieldtype="MultiCheck"]'); + + let $no_results = $wrapper.find(".no-results-message"); + if (!$no_results.length) { + $no_results = $(` + + `).appendTo($wrapper); + } + + $no_results.hide(); + + const matches_filter = ($el, filter) => { + const $text_el = $el.find(text_class); + const text = $text_el.text().toLowerCase(); + + let name = ""; + if (data_attr && $text_el.attr(data_attr)) { + name = $text_el.attr(data_attr).toLowerCase(); + } + + return text.includes(filter) || name.includes(filter); + }; $search_input.off("keyup").on("keyup", () => { - let text_filter = $search_input.val().toLowerCase(); - // Replace trailing and leading spaces - text_filter = text_filter.replace(/^\s+|\s+$/g, ""); - for (let i = 0; i < $elements.length; i++) { - const text_element = $elements.eq(i).find(text_class); - const text = text_element.text().toLowerCase(); + const text_filter = $search_input.val().toLowerCase().trim(); + let any_visible = false; - let name = ""; - if (data_attr && text_element.attr(data_attr)) { - name = text_element.attr(data_attr).toLowerCase(); - } + $elements.each(function () { + const match = matches_filter($(this), text_filter); + $(this).toggle(match); + if (match) any_visible = true; + }); - if (text.includes(text_filter) || name.includes(text_filter)) { - $elements.eq(i).css("display", ""); - } else { - $elements.eq(i).css("display", "none"); - } + if ($multichecks.length) { + $multichecks.show(); + + $multichecks.each(function () { + const has_visible = $(this).find(el_class + ":visible").length; + $(this).toggle(!!has_visible); + }); + } + + if (text_filter) { + $no_results.toggle(!any_visible); + } else { + $no_results.hide(); } }); }, From 63b8fd81742127c27132feeac18eb539fcc6a531 Mon Sep 17 00:00:00 2001 From: MochaMind Date: Sun, 11 Jan 2026 23:22:03 +0530 Subject: [PATCH 175/401] fix: Indonesian translations (#35824) --- frappe/locale/id.po | 155 ++++++++++++++++++++++---------------------- 1 file changed, 78 insertions(+), 77 deletions(-) diff --git a/frappe/locale/id.po b/frappe/locale/id.po index de5b786a64..2c9c596b70 100644 --- a/frappe/locale/id.po +++ b/frappe/locale/id.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: developers@frappe.io\n" "POT-Creation-Date: 2025-12-21 09:35+0000\n" -"PO-Revision-Date: 2025-12-24 20:24\n" +"PO-Revision-Date: 2026-01-11 00:40\n" "Last-Translator: developers@frappe.io\n" "Language-Team: Indonesian\n" "MIME-Version: 1.0\n" @@ -22,13 +22,13 @@ msgstr "" #. Condition' #: frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.json msgid "!=" -msgstr "" +msgstr "!=" #. Description of the 'Org History Heading' (Data) field in DocType 'About Us #. Settings' #: frappe/website/doctype/about_us_settings/about_us_settings.json msgid "\"Company History\"" -msgstr "" +msgstr "\"Riwayat Perusahaan\"" #: frappe/core/doctype/data_export/exporter.py:202 msgid "\"Parent\" signifies the parent table in which this row must be added" @@ -38,7 +38,7 @@ msgstr "\"Induk\" menandakan tabel induk di mana baris ini harus ditambahkan" #. Settings' #: frappe/website/doctype/about_us_settings/about_us_settings.json msgid "\"Team Members\" or \"Management\"" -msgstr "" +msgstr "\"Anggota Tim\" atau \"Manajemen\"" #: frappe/public/js/frappe/form/form.js:1093 msgid "\"amended_from\" field must be present to do an amendment." @@ -55,16 +55,16 @@ msgstr "" #: frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.js:36 msgid "${values.doctype_name} has been added to queue for optimization" -msgstr "" +msgstr "${values.doctype_name} telah ditambahkan ke antrian untuk optimasi" #: frappe/public/js/frappe/ui/toolbar/about.js:11 msgid "© Frappe Technologies Pvt. Ltd. and contributors" -msgstr "" +msgstr "© Frappe Technologies Pvt. Ltd. dan kontributor" #. Label of the head_html (Code) field in DocType 'Website Settings' #: frappe/website/doctype/website_settings/website_settings.json msgid "<head> HTML" -msgstr "" +msgstr "<head> HTML" #: frappe/database/query.py:2100 msgid "'*' is only allowed in {0} SQL function(s)" @@ -72,7 +72,7 @@ msgstr "" #: frappe/public/js/form_builder/store.js:206 msgid "'In Global Search' is not allowed for field {0} of type {1}" -msgstr "" +msgstr "'Dalam Pencarian Global' tidak diizinkan untuk bidang {0} dengan tipe {1}" #: frappe/core/doctype/doctype/doctype.py:1369 msgid "'In Global Search' not allowed for type {0} in row {1}" @@ -80,7 +80,7 @@ msgstr "'Di Pencarian Global' tidak dibolehkan jenis {0} pada baris {1}" #: frappe/public/js/form_builder/store.js:198 msgid "'In List View' is not allowed for field {0} of type {1}" -msgstr "" +msgstr "'Dalam Tampilan Daftar' tidak diizinkan untuk bidang {0} dengan tipe {1}" #: frappe/custom/doctype/customize_form/customize_form.py:367 msgid "'In List View' not allowed for type {0} in row {1}" @@ -96,7 +96,7 @@ msgstr "" #: frappe/utils/__init__.py:258 msgid "'{0}' is not a valid URL" -msgstr "" +msgstr "'{0}' bukan URL yang valid" #: frappe/core/doctype/doctype/doctype.py:1363 msgid "'{0}' not allowed for type {1} in row {2}" @@ -104,7 +104,7 @@ msgstr "'{0}' tidak diperbolehkan untuk jenis {1} di baris {2}" #: frappe/public/js/frappe/data_import/data_exporter.js:302 msgid "(Mandatory)" -msgstr "" +msgstr "(Wajib)" #: frappe/model/rename_doc.py:703 msgid "** Failed: {0} to {1}: {2}" @@ -113,13 +113,13 @@ msgstr "** Gagal: {0} ke {1}: {2}" #: frappe/public/js/frappe/list/list_settings.js:133 #: frappe/public/js/frappe/views/kanban/kanban_settings.js:111 msgid "+ Add / Remove Fields" -msgstr "" +msgstr "+ Tambah / Hapus Bidang" #. Description of the 'Doc Status' (Select) field in DocType 'Workflow Document #. State' #: frappe/workflow/doctype/workflow_document_state/workflow_document_state.json msgid "0 - Draft; 1 - Submitted; 2 - Cancelled" -msgstr "" +msgstr "0 - Draf; 1 - Diajukan; 2 - Dibatalkan" #. Description of the 'Minimum Password Score' (Select) field in DocType #. 'System Settings' @@ -138,21 +138,22 @@ msgstr "" #. Description of the 'Priority' (Int) field in DocType 'Web Page' #: frappe/website/doctype/web_page/web_page.json msgid "0 is highest" -msgstr "" +msgstr "0 adalah tertinggi" #: frappe/public/js/frappe/form/grid_row.js:892 msgid "1 = True & 0 = False" -msgstr "" +msgstr "1 = Benar & 0 = Salah" #. Description of the 'Fraction Units' (Int) field in DocType 'Currency' #: frappe/geo/doctype/currency/currency.json msgid "1 Currency = [?] Fraction\n" "For e.g. 1 USD = 100 Cent" -msgstr "" +msgstr "1 Mata Uang = [?] Pecahan\n" +"Contoh: 1 USD = 100 Sen" #: frappe/public/js/frappe/form/reminders.js:19 msgid "1 Day" -msgstr "" +msgstr "1 Hari" #: frappe/integrations/doctype/google_calendar/google_calendar.py:374 msgid "1 Google Calendar Event synced." @@ -160,15 +161,15 @@ msgstr "1 Acara Kalender Google disinkronkan." #: frappe/public/js/frappe/views/reports/query_report.js:966 msgid "1 Report" -msgstr "" +msgstr "1 Laporan" #: frappe/tests/test_utils.py:906 msgid "1 day ago" -msgstr "" +msgstr "1 hari yang lalu" #: frappe/public/js/frappe/form/reminders.js:17 msgid "1 hour" -msgstr "" +msgstr "1 jam" #: frappe/public/js/frappe/utils/pretty_date.js:52 #: frappe/tests/test_utils.py:904 @@ -187,7 +188,7 @@ msgstr "1 bulan lalu" #: frappe/public/js/print_format_builder/PrintFormat.vue:3 msgid "1 of 2" -msgstr "" +msgstr "1 dari 2" #: frappe/public/js/frappe/data_import/data_exporter.js:227 msgid "1 record will be exported" @@ -205,7 +206,7 @@ msgstr "" #: frappe/tests/test_utils.py:901 msgid "1 second ago" -msgstr "" +msgstr "1 detik yang lalu" #: frappe/public/js/frappe/utils/pretty_date.js:62 #: frappe/tests/test_utils.py:908 @@ -219,31 +220,31 @@ msgstr "1 tahun yang lalu" #: frappe/tests/test_utils.py:905 msgid "2 hours ago" -msgstr "" +msgstr "2 jam yang lalu" #: frappe/tests/test_utils.py:911 msgid "2 months ago" -msgstr "" +msgstr "2 bulan lalu" #: frappe/tests/test_utils.py:909 msgid "2 weeks ago" -msgstr "" +msgstr "2 minggu yang lalu" #: frappe/tests/test_utils.py:913 msgid "2 years ago" -msgstr "" +msgstr "2 tahun yang lalu" #: frappe/tests/test_utils.py:903 msgid "3 minutes ago" -msgstr "" +msgstr "3 menit yang lalu" #: frappe/public/js/frappe/form/reminders.js:16 msgid "30 minutes" -msgstr "" +msgstr "30 menit" #: frappe/public/js/frappe/form/reminders.js:18 msgid "4 hours" -msgstr "" +msgstr "4 jam" #: frappe/public/js/frappe/data_import/data_exporter.js:37 msgid "5 Records" @@ -251,7 +252,7 @@ msgstr "5 catatan" #: frappe/tests/test_utils.py:907 msgid "5 days ago" -msgstr "" +msgstr "5 hari yang lalu" #: frappe/desk/doctype/bulk_update/bulk_update.py:36 msgid "; not allowed in condition" @@ -261,13 +262,13 @@ msgstr "; tidak diijinkan dalam kondisi" #. Condition' #: frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.json msgid "<" -msgstr "" +msgstr "<" #. Option for the 'Condition' (Select) field in DocType 'Document Naming Rule #. Condition' #: frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.json msgid "<=" -msgstr "" +msgstr "<=" #. Description of the 'Generate Keys' (Button) field in DocType 'User' #: frappe/core/doctype/user/user.json @@ -278,7 +279,7 @@ msgstr "" #: frappe/public/js/frappe/widgets/widget_dialog.js:601 msgid "{0} is not a valid URL" -msgstr "" +msgstr "{0} bukan URL yang valid" #. Content of the 'Help' (HTML) field in DocType 'Property Setter' #: frappe/custom/doctype/property_setter/property_setter.json @@ -625,7 +626,7 @@ msgstr "{0} {1} berulang telah dibuat untuk Anda melalui Ulangi Otomatis {2}." #. Description of the 'Symbol' (Data) field in DocType 'Currency' #: frappe/geo/doctype/currency/currency.json msgid "A symbol for this currency. For e.g. $" -msgstr "" +msgstr "Simbol untuk mata uang ini. Contoh. $" #: frappe/printing/doctype/print_format_field_template/print_format_field_template.py:49 msgid "A template already exists for field {0} of {1}" @@ -645,67 +646,67 @@ msgstr "Sebuah kata dengan sendirinya mudah ditebak." #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A0" -msgstr "" +msgstr "A0" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A1" -msgstr "" +msgstr "A1" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A2" -msgstr "" +msgstr "A2" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A3" -msgstr "" +msgstr "A3" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A4" -msgstr "" +msgstr "A4" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A5" -msgstr "" +msgstr "A5" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A6" -msgstr "" +msgstr "A6" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A7" -msgstr "" +msgstr "A7" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A8" -msgstr "" +msgstr "A8" #. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings' #: frappe/printing/doctype/print_settings/print_settings.json msgid "A9" -msgstr "" +msgstr "A9" #. Option for the 'Email Sync Option' (Select) field in DocType 'Email Account' #: frappe/email/doctype/email_account/email_account.json msgid "ALL" -msgstr "" +msgstr "SEMUA" #. Option for the 'Script Type' (Select) field in DocType 'Server Script' #: frappe/core/doctype/server_script/server_script.json msgid "API" -msgstr "" +msgstr "API" #. Label of the api_access (Section Break) field in DocType 'User' #: frappe/core/doctype/user/user.json msgid "API Access" -msgstr "" +msgstr "Akses API" #. Label of the api_endpoint (Data) field in DocType 'Social Login Key' #: frappe/integrations/doctype/social_login_key/social_login_key.json @@ -918,7 +919,7 @@ msgstr "" #: frappe/public/js/frappe/widgets/onboarding_widget.js:305 #: frappe/public/js/frappe/widgets/onboarding_widget.js:376 msgid "Action Complete" -msgstr "" +msgstr "Tindakan Selesai" #: frappe/model/document.py:1941 msgid "Action Failed" @@ -927,7 +928,7 @@ msgstr "aksi Gagal" #. Label of the action_label (Data) field in DocType 'Onboarding Step' #: frappe/desk/doctype/onboarding_step/onboarding_step.json msgid "Action Label" -msgstr "" +msgstr "Label Tindakan" #. Label of the action_timeout (Int) field in DocType 'Success Action' #: frappe/core/doctype/success_action/success_action.json @@ -945,7 +946,7 @@ msgstr "" #: frappe/core/doctype/submission_queue/submission_queue.py:116 msgid "Action {0} failed on {1} {2}. View it {3}" -msgstr "" +msgstr "Tindakan {0} gagal pada {1} {2}. Lihat {3}" #. Label of the actions_section (Tab Break) field in DocType 'DocType' #. Label of the actions_section (Section Break) field in DocType 'User Session @@ -982,7 +983,7 @@ msgstr "Tindakan" #. Label of the activate (Check) field in DocType 'Package Import' #: frappe/core/doctype/package_import/package_import.json msgid "Activate" -msgstr "" +msgstr "Aktifkan" #. Option for the 'Status' (Select) field in DocType 'Auto Repeat' #. Option for the 'Status' (Select) field in DocType 'Kanban Board Column' @@ -999,14 +1000,14 @@ msgstr "Aktif" #. Option for the 'Directory Server' (Select) field in DocType 'LDAP Settings' #: frappe/integrations/doctype/ldap_settings/ldap_settings.json msgid "Active Directory" -msgstr "" +msgstr "Direktori Aktif" #. Label of the active_domains_sb (Section Break) field in DocType 'Domain #. Settings' #. Label of the active_domains (Table) field in DocType 'Domain Settings' #: frappe/core/doctype/domain_settings/domain_settings.json msgid "Active Domains" -msgstr "" +msgstr "Domain Aktif" #. Label of the active_sessions (Table) field in DocType 'User' #. Label of the active_sessions (Int) field in DocType 'System Health Report' @@ -1047,7 +1048,7 @@ msgstr "Tambahkan" #: frappe/public/js/frappe/form/grid_row.js:454 msgid "Add / Remove Columns" -msgstr "" +msgstr "Tambah / Hapus Kolom" #: frappe/core/doctype/user_permission/user_permission_list.js:4 msgid "Add / Update" @@ -1065,21 +1066,21 @@ msgstr "Tambahkan lampiran" #. Label of the add_background_image (Check) field in DocType 'Web Page Block' #: frappe/website/doctype/web_page_block/web_page_block.json msgid "Add Background Image" -msgstr "" +msgstr "Tambah Gambar Latar Belakang" #. Label of the add_border_at_bottom (Check) field in DocType 'Web Page Block' #: frappe/website/doctype/web_page_block/web_page_block.json msgid "Add Border at Bottom" -msgstr "" +msgstr "Tambah Garis Tepi di Bawah" #. Label of the add_border_at_top (Check) field in DocType 'Web Page Block' #: frappe/website/doctype/web_page_block/web_page_block.json msgid "Add Border at Top" -msgstr "" +msgstr "Tambah Garis Tepi di Atas" #: frappe/desk/doctype/number_card/number_card.js:37 msgid "Add Card to Dashboard" -msgstr "" +msgstr "Tambah Kartu ke Dasbor" #: frappe/public/js/frappe/views/reports/query_report.js:210 msgid "Add Chart to Dashboard" @@ -1114,7 +1115,7 @@ msgstr "" #. Label of the set_meta_tags (Button) field in DocType 'Web Page' #: frappe/website/doctype/web_page/web_page.json msgid "Add Custom Tags" -msgstr "" +msgstr "Tambah Tag Kustom" #: frappe/public/js/frappe/widgets/widget_dialog.js:188 #: frappe/public/js/frappe/widgets/widget_dialog.js:716 @@ -1124,7 +1125,7 @@ msgstr "Tambahkan Filter" #. Label of the add_shade (Check) field in DocType 'Web Page Block' #: frappe/website/doctype/web_page_block/web_page_block.json msgid "Add Gray Background" -msgstr "" +msgstr "Tambah Latar Belakang Abu-abu" #: frappe/public/js/frappe/ui/group_by/group_by.js:230 #: frappe/public/js/frappe/ui/group_by/group_by.js:430 @@ -1137,7 +1138,7 @@ msgstr "" #: frappe/public/js/frappe/form/grid.js:66 msgid "Add Multiple" -msgstr "" +msgstr "Tambah Beberapa" #: frappe/core/page/permission_manager/permission_manager.js:495 msgid "Add New Permission Rule" @@ -1150,15 +1151,15 @@ msgstr "Tambahkan Peserta" #. Label of the add_query_parameters (Check) field in DocType 'Email Group' #: frappe/email/doctype/email_group/email_group.json msgid "Add Query Parameters" -msgstr "" +msgstr "Tambah Parameter Kueri" #: frappe/core/doctype/user/user.py:857 msgid "Add Roles" -msgstr "" +msgstr "Tambah Peran" #: frappe/public/js/frappe/form/grid.js:66 msgid "Add Row" -msgstr "" +msgstr "Tambah Baris" #. Label of the add_signature (Check) field in DocType 'Email Account' #: frappe/email/doctype/email_account/email_account.json @@ -1169,12 +1170,12 @@ msgstr "Tambahkan Signature" #. Label of the add_bottom_padding (Check) field in DocType 'Web Page Block' #: frappe/website/doctype/web_page_block/web_page_block.json msgid "Add Space at Bottom" -msgstr "" +msgstr "Tambah Spasi di Bawah" #. Label of the add_top_padding (Check) field in DocType 'Web Page Block' #: frappe/website/doctype/web_page_block/web_page_block.json msgid "Add Space at Top" -msgstr "" +msgstr "Tambah Spasi di Atas" #: frappe/email/doctype/email_group/email_group.js:38 #: frappe/email/doctype/email_group/email_group.js:59 @@ -1183,12 +1184,12 @@ msgstr "Tambahkan Pelanggan" #: frappe/public/js/frappe/list/bulk_operations.js:425 msgid "Add Tags" -msgstr "" +msgstr "Tambah Tag" #: frappe/public/js/frappe/list/list_view.js:2228 msgctxt "Button in list view actions menu" msgid "Add Tags" -msgstr "" +msgstr "Tambah Tag" #: frappe/public/js/frappe/views/communication.js:424 msgid "Add Template" @@ -1476,13 +1477,13 @@ msgstr "" #: frappe/core/doctype/doctype/doctype.json #: frappe/core/doctype/system_settings/system_settings.json msgid "Advanced" -msgstr "" +msgstr "Lanjutan" #. Label of the advanced_control_section (Section Break) field in DocType 'User #. Permission' #: frappe/core/doctype/user_permission/user_permission.json msgid "Advanced Control" -msgstr "" +msgstr "Kontrol Lanjutan" #: frappe/public/js/frappe/form/controls/link.js:485 #: frappe/public/js/frappe/form/controls/link.js:487 @@ -1492,22 +1493,22 @@ msgstr "Pencarian Lanjutan" #. Label of the sb_advanced (Section Break) field in DocType 'OAuth Client' #: frappe/integrations/doctype/oauth_client/oauth_client.json msgid "Advanced Settings" -msgstr "" +msgstr "Pengaturan Lanjutan" #: frappe/public/js/frappe/ui/filters/filter.js:64 #: frappe/public/js/frappe/ui/filters/filter.js:70 msgid "After" -msgstr "" +msgstr "Setelah" #. Option for the 'DocType Event' (Select) field in DocType 'Server Script' #: frappe/core/doctype/server_script/server_script.json msgid "After Cancel" -msgstr "" +msgstr "Setelah Batal" #. Option for the 'DocType Event' (Select) field in DocType 'Server Script' #: frappe/core/doctype/server_script/server_script.json msgid "After Delete" -msgstr "" +msgstr "Setelah Hapus" #. Option for the 'DocType Event' (Select) field in DocType 'Server Script' #: frappe/core/doctype/server_script/server_script.json @@ -1517,17 +1518,17 @@ msgstr "" #. Option for the 'DocType Event' (Select) field in DocType 'Server Script' #: frappe/core/doctype/server_script/server_script.json msgid "After Insert" -msgstr "" +msgstr "Setelah Sisip" #. Option for the 'DocType Event' (Select) field in DocType 'Server Script' #: frappe/core/doctype/server_script/server_script.json msgid "After Rename" -msgstr "" +msgstr "Setelah Ubah Nama" #. Option for the 'DocType Event' (Select) field in DocType 'Server Script' #: frappe/core/doctype/server_script/server_script.json msgid "After Save" -msgstr "" +msgstr "Setelah Simpan" #. Option for the 'DocType Event' (Select) field in DocType 'Server Script' #: frappe/core/doctype/server_script/server_script.json From 65d4e1262d41870c7147086b07c34a1926d31503 Mon Sep 17 00:00:00 2001 From: sokumon Date: Sun, 11 Jan 2026 23:34:25 +0530 Subject: [PATCH 176/401] fix: add workspace for users --- frappe/core/workspace/users/users.json | 122 ++++-------------- .../login_activity/login_activity.json | 34 +++++ .../failed_login_attempts.json | 6 +- .../total_website_users.json | 26 ++++ frappe/desk/number_card/users/users.json | 27 ++++ .../js/frappe/views/workspace/workspace.js | 8 +- 6 files changed, 123 insertions(+), 100 deletions(-) create mode 100644 frappe/desk/dashboard_chart/login_activity/login_activity.json create mode 100644 frappe/desk/number_card/total_website_users/total_website_users.json create mode 100644 frappe/desk/number_card/users/users.json diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json index 054685adcc..fd22915df1 100644 --- a/frappe/core/workspace/users/users.json +++ b/frappe/core/workspace/users/users.json @@ -1,6 +1,12 @@ { - "charts": [], - "content": "[{\"id\":\"b7abeqw4NZ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User\",\"col\":3}},{\"id\":\"eghSJPhZRC\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Role\",\"col\":3}},{\"id\":\"uAzl_lT_C0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Permission Manager\",\"col\":3}},{\"id\":\"oFB4l28FMU\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"NMpIkExl3i\",\"type\":\"card\",\"data\":{\"card_name\":\"Users\",\"col\":4}},{\"id\":\"VepG3durKm\",\"type\":\"card\",\"data\":{\"card_name\":\"Logs\",\"col\":4}},{\"id\":\"S9FeWt7xXE\",\"type\":\"card\",\"data\":{\"card_name\":\"Permissions\",\"col\":4}}]", + "app": "frappe", + "charts": [ + { + "chart_name": "Login", + "label": "Login Activity" + } + ], + "content": "[{\"id\":\"T_8h_1kB6j\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Login Activity\",\"col\":12}},{\"id\":\"Y9G8gIH9lP\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"System Users\",\"col\":4}},{\"id\":\"78JTmWaYfY\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Website Users\",\"col\":4}},{\"id\":\"vAh1zw5jLk\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Failed Login Attempts\",\"col\":4}}]", "creation": "2020-03-02 15:12:16.754449", "custom_blocks": [], "docstatus": 0, @@ -12,14 +18,6 @@ "is_hidden": 0, "label": "Users", "links": [ - { - "hidden": 0, - "is_query_report": 0, - "label": "Logs", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "", "hidden": 0, @@ -42,14 +40,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Permissions", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "", "hidden": 0, @@ -105,65 +95,26 @@ "onboard": 0, "report_ref_doctype": "DocShare", "type": "Link" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Users", - "link_count": 4, - "link_type": "DocType", - "onboard": 0, - "type": "Card Break" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "User", - "link_count": 0, - "link_to": "User", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Role", - "link_count": 0, - "link_to": "Role", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Role Profile", - "link_count": 0, - "link_to": "Role Profile", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Module Profile", - "link_count": 0, - "link_to": "Module Profile", - "link_type": "DocType", - "onboard": 0, - "type": "Link" } ], - "modified": "2024-08-19 11:48:35.908082", + "modified": "2026-01-11 23:30:10.696298", "modified_by": "Administrator", "module": "Core", "name": "Users", - "number_cards": [], + "number_cards": [ + { + "label": "Website Users", + "number_card_name": "Total Website Users" + }, + { + "label": "System Users", + "number_card_name": "Users" + }, + { + "label": "Failed Login Attempts", + "number_card_name": "Failed Login Attempts" + } + ], "owner": "Administrator", "parent_page": "", "public": 1, @@ -171,26 +122,7 @@ "restrict_to_domain": "", "roles": [], "sequence_id": 13.0, - "shortcuts": [ - { - "doc_view": "", - "label": "User", - "link_to": "User", - "stats_filter": "[]", - "type": "DocType" - }, - { - "doc_view": "", - "label": "Role", - "link_to": "Role", - "stats_filter": "[]", - "type": "DocType" - }, - { - "label": "Permission Manager", - "link_to": "permission-manager", - "type": "Page" - } - ], - "title": "Users" -} \ No newline at end of file + "shortcuts": [], + "title": "Users", + "type": "Workspace" +} diff --git a/frappe/desk/dashboard_chart/login_activity/login_activity.json b/frappe/desk/dashboard_chart/login_activity/login_activity.json new file mode 100644 index 0000000000..f09a839290 --- /dev/null +++ b/frappe/desk/dashboard_chart/login_activity/login_activity.json @@ -0,0 +1,34 @@ +{ + "based_on": "communication_date", + "chart_name": "Login Activity", + "chart_type": "Count", + "creation": "2025-08-28 16:48:49.946848", + "currency": "INR", + "docstatus": 0, + "doctype": "Dashboard Chart", + "document_type": "Activity Log", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Activity Log\",\"status\",\"=\",\"Success\",false]]", + "group_by_type": "Count", + "idx": 1, + "is_public": 1, + "is_standard": 1, + "last_synced_on": "2026-01-11 23:31:45.859669", + "modified": "2026-01-11 23:32:53.440322", + "modified_by": "Administrator", + "module": "Desk", + "name": "Login Activity", + "number_of_groups": 0, + "owner": "Administrator", + "parent_document_type": "", + "roles": [], + "show_values_over_chart": 0, + "source": "", + "time_interval": "Daily", + "timeseries": 1, + "timespan": "Last Week", + "type": "Line", + "use_report_chart": 0, + "value_based_on": "", + "y_axis": [] +} diff --git a/frappe/desk/number_card/failed_login_attempts/failed_login_attempts.json b/frappe/desk/number_card/failed_login_attempts/failed_login_attempts.json index 89854e1523..f5f2da8c3a 100644 --- a/frappe/desk/number_card/failed_login_attempts/failed_login_attempts.json +++ b/frappe/desk/number_card/failed_login_attempts/failed_login_attempts.json @@ -7,13 +7,13 @@ "doctype": "Number Card", "document_type": "Activity Log", "dynamic_filters_json": "[]", - "filters_json": "[[\"Activity Log\",\"status\",\"=\",\"Failed\"]]", + "filters_json": "[[\"Activity Log\",\"status\",\"=\",\"Failed\",false]]", "function": "Count", "idx": 0, - "is_public": 0, + "is_public": 1, "is_standard": 1, "label": "Failed Login Attempts", - "modified": "2025-08-31 19:21:55.040453", + "modified": "2026-01-11 23:33:20.621927", "modified_by": "Administrator", "module": "Desk", "name": "Failed Login Attempts", diff --git a/frappe/desk/number_card/total_website_users/total_website_users.json b/frappe/desk/number_card/total_website_users/total_website_users.json new file mode 100644 index 0000000000..68860af629 --- /dev/null +++ b/frappe/desk/number_card/total_website_users/total_website_users.json @@ -0,0 +1,26 @@ +{ + "aggregate_function_based_on": "", + "creation": "2025-08-21 04:10:39.412970", + "currency": "INR", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "User", + "dynamic_filters_json": "[]", + "filters_json": "[[\"User\",\"user_type\",\"=\",\"Website User\"]]", + "function": "Count", + "idx": 1, + "is_public": 1, + "is_standard": 1, + "label": "Total Website Users", + "modified": "2026-01-11 23:33:03.363564", + "modified_by": "Administrator", + "module": "Desk", + "name": "Total Website Users", + "owner": "Administrator", + "parent_document_type": "", + "report_function": "Sum", + "show_full_number": 0, + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} diff --git a/frappe/desk/number_card/users/users.json b/frappe/desk/number_card/users/users.json new file mode 100644 index 0000000000..f51e56bf05 --- /dev/null +++ b/frappe/desk/number_card/users/users.json @@ -0,0 +1,27 @@ +{ + "aggregate_function_based_on": "", + "color": "#29CD42", + "creation": "2025-08-21 01:13:53.957596", + "currency": "INR", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "User", + "dynamic_filters_json": "[]", + "filters_json": "[[\"User\",\"user_type\",\"=\",\"System User\",false]]", + "function": "Count", + "idx": 2, + "is_public": 1, + "is_standard": 1, + "label": "Total System Users", + "modified": "2026-01-11 23:33:06.681417", + "modified_by": "Administrator", + "module": "Desk", + "name": "Users", + "owner": "Administrator", + "parent_document_type": "", + "report_function": "Sum", + "show_full_number": 0, + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index 7b23695b53..e1e23d06af 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -276,7 +276,7 @@ frappe.views.Workspace = class Workspace { if (!app && this._page.module) { app = frappe.boot.module_app[frappe.router.slug(this._page.module)]; } - this._page.module && this.sidebar.show_sidebar_for_module(this._page.module); + // this._page.module && this.sidebar.show_sidebar_for_module(this._page.module); if (!app) app = "frappe"; } @@ -778,7 +778,11 @@ frappe.views.Workspace = class Workspace { message: __("Saved"), indicator: "green", }); - frappe.set_route("desk", "private", page.title); + if (page.public) { + frappe.set_route("desk", page.title.toLowerCase()); + } else { + frappe.set_route("desk", "private", page.title.toLowerCase()); + } } }, }); From 1c50e3d73d74d228180d21d66ca7f833717637a5 Mon Sep 17 00:00:00 2001 From: sokumon Date: Sun, 11 Jan 2026 23:43:11 +0530 Subject: [PATCH 177/401] fix: cleanup workspace for website --- .../login_activity/login_activity.json | 6 ++-- .../failed_login_attempts.json | 4 +-- .../published_web_forms.json | 6 ++-- .../total_website_users.json | 4 +-- frappe/desk/number_card/users/users.json | 4 +-- .../webpage_views/webpage_views.json | 35 +++++++++++++++++++ frappe/website/workspace/website/website.json | 4 +-- 7 files changed, 49 insertions(+), 14 deletions(-) create mode 100644 frappe/website/dashboard_chart/webpage_views/webpage_views.json diff --git a/frappe/desk/dashboard_chart/login_activity/login_activity.json b/frappe/desk/dashboard_chart/login_activity/login_activity.json index f09a839290..7298a54c52 100644 --- a/frappe/desk/dashboard_chart/login_activity/login_activity.json +++ b/frappe/desk/dashboard_chart/login_activity/login_activity.json @@ -11,10 +11,10 @@ "filters_json": "[[\"Activity Log\",\"status\",\"=\",\"Success\",false]]", "group_by_type": "Count", "idx": 1, - "is_public": 1, + "is_public": 0, "is_standard": 1, - "last_synced_on": "2026-01-11 23:31:45.859669", - "modified": "2026-01-11 23:32:53.440322", + "last_synced_on": "2026-01-11 23:34:36.361407", + "modified": "2026-01-11 23:37:58.619758", "modified_by": "Administrator", "module": "Desk", "name": "Login Activity", diff --git a/frappe/desk/number_card/failed_login_attempts/failed_login_attempts.json b/frappe/desk/number_card/failed_login_attempts/failed_login_attempts.json index f5f2da8c3a..ee15748684 100644 --- a/frappe/desk/number_card/failed_login_attempts/failed_login_attempts.json +++ b/frappe/desk/number_card/failed_login_attempts/failed_login_attempts.json @@ -10,10 +10,10 @@ "filters_json": "[[\"Activity Log\",\"status\",\"=\",\"Failed\",false]]", "function": "Count", "idx": 0, - "is_public": 1, + "is_public": 0, "is_standard": 1, "label": "Failed Login Attempts", - "modified": "2026-01-11 23:33:20.621927", + "modified": "2026-01-11 23:37:25.824490", "modified_by": "Administrator", "module": "Desk", "name": "Failed Login Attempts", diff --git a/frappe/desk/number_card/published_web_forms/published_web_forms.json b/frappe/desk/number_card/published_web_forms/published_web_forms.json index 314ae14a6b..1115c3db1f 100644 --- a/frappe/desk/number_card/published_web_forms/published_web_forms.json +++ b/frappe/desk/number_card/published_web_forms/published_web_forms.json @@ -7,13 +7,13 @@ "doctype": "Number Card", "document_type": "Web Form", "dynamic_filters_json": "[]", - "filters_json": "[[\"Web Form\",\"published\",\"=\",1]]", + "filters_json": "[[\"Web Form\",\"published\",\"=\",1,false]]", "function": "Count", - "idx": 0, + "idx": 1, "is_public": 0, "is_standard": 1, "label": "Published Web Forms", - "modified": "2025-09-08 11:23:24.431998", + "modified": "2026-01-11 23:36:48.565273", "modified_by": "Administrator", "module": "Desk", "name": "Published Web Forms", diff --git a/frappe/desk/number_card/total_website_users/total_website_users.json b/frappe/desk/number_card/total_website_users/total_website_users.json index 68860af629..01af1b9f8a 100644 --- a/frappe/desk/number_card/total_website_users/total_website_users.json +++ b/frappe/desk/number_card/total_website_users/total_website_users.json @@ -9,10 +9,10 @@ "filters_json": "[[\"User\",\"user_type\",\"=\",\"Website User\"]]", "function": "Count", "idx": 1, - "is_public": 1, + "is_public": 0, "is_standard": 1, "label": "Total Website Users", - "modified": "2026-01-11 23:33:03.363564", + "modified": "2026-01-11 23:37:03.758465", "modified_by": "Administrator", "module": "Desk", "name": "Total Website Users", diff --git a/frappe/desk/number_card/users/users.json b/frappe/desk/number_card/users/users.json index f51e56bf05..31b74dd490 100644 --- a/frappe/desk/number_card/users/users.json +++ b/frappe/desk/number_card/users/users.json @@ -10,10 +10,10 @@ "filters_json": "[[\"User\",\"user_type\",\"=\",\"System User\",false]]", "function": "Count", "idx": 2, - "is_public": 1, + "is_public": 0, "is_standard": 1, "label": "Total System Users", - "modified": "2026-01-11 23:33:06.681417", + "modified": "2026-01-11 23:37:07.673546", "modified_by": "Administrator", "module": "Desk", "name": "Users", diff --git a/frappe/website/dashboard_chart/webpage_views/webpage_views.json b/frappe/website/dashboard_chart/webpage_views/webpage_views.json new file mode 100644 index 0000000000..8d5379da34 --- /dev/null +++ b/frappe/website/dashboard_chart/webpage_views/webpage_views.json @@ -0,0 +1,35 @@ +{ + "based_on": "modified", + "chart_name": "Webpage Views", + "chart_type": "Count", + "creation": "2025-09-04 11:09:27.279328", + "currency": "", + "docstatus": 0, + "doctype": "Dashboard Chart", + "document_type": "Web Page View", + "dynamic_filters_json": "[]", + "filters_json": "[]", + "group_by_based_on": "owner", + "group_by_type": "Sum", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "last_synced_on": "2026-01-11 23:34:40.464683", + "modified": "2026-01-11 23:39:02.985218", + "modified_by": "Administrator", + "module": "Website", + "name": "Webpage Views", + "number_of_groups": 0, + "owner": "Administrator", + "parent_document_type": "", + "roles": [], + "show_values_over_chart": 0, + "source": "", + "time_interval": "Weekly", + "timeseries": 1, + "timespan": "Last Year", + "type": "Line", + "use_report_chart": 0, + "value_based_on": "", + "y_axis": [] +} diff --git a/frappe/website/workspace/website/website.json b/frappe/website/workspace/website/website.json index c675d65e65..ab13efe646 100644 --- a/frappe/website/workspace/website/website.json +++ b/frappe/website/workspace/website/website.json @@ -2,7 +2,7 @@ "app": "frappe", "charts": [ { - "chart_name": "Web Page", + "chart_name": "Webpage Views", "label": "Website Visits" } ], @@ -96,7 +96,7 @@ "type": "Link" } ], - "modified": "2025-11-13 13:55:20.457550", + "modified": "2026-01-11 23:42:31.758169", "modified_by": "Administrator", "module": "Website", "name": "Website", From 2c423c255c9fd74749a05f2b1229aacda9eb10ce Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 00:04:24 +0530 Subject: [PATCH 178/401] fix: recreate system workspace --- .../background_job_activity.json | 4 +-- .../notifications_by_type.json | 35 +++++++++++++++++++ .../active_rq_worker/active_rq_worker.json | 26 ++++++++++++++ .../number_card/error_logs/error_logs.json | 27 ++++++++++++++ .../scheduled_jobs/scheduled_jobs.json | 26 ++++++++++++++ frappe/core/workspace/system/system.json | 19 ++++++++-- 6 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 frappe/core/dashboard_chart/notifications_by_type/notifications_by_type.json create mode 100644 frappe/core/number_card/active_rq_worker/active_rq_worker.json create mode 100644 frappe/core/number_card/error_logs/error_logs.json create mode 100644 frappe/core/number_card/scheduled_jobs/scheduled_jobs.json diff --git a/frappe/core/dashboard_chart/background_job_activity/background_job_activity.json b/frappe/core/dashboard_chart/background_job_activity/background_job_activity.json index 72d45e614d..ed1891bbb1 100644 --- a/frappe/core/dashboard_chart/background_job_activity/background_job_activity.json +++ b/frappe/core/dashboard_chart/background_job_activity/background_job_activity.json @@ -13,8 +13,8 @@ "idx": 0, "is_public": 0, "is_standard": 1, - "last_synced_on": "2025-10-30 21:36:33.646973", - "modified": "2025-10-30 21:37:11.340673", + "last_synced_on": "2026-01-12 00:01:03.263885", + "modified": "2026-01-12 00:03:10.123061", "modified_by": "Administrator", "module": "Core", "name": "Background Job Activity", diff --git a/frappe/core/dashboard_chart/notifications_by_type/notifications_by_type.json b/frappe/core/dashboard_chart/notifications_by_type/notifications_by_type.json new file mode 100644 index 0000000000..4e1f74c989 --- /dev/null +++ b/frappe/core/dashboard_chart/notifications_by_type/notifications_by_type.json @@ -0,0 +1,35 @@ +{ + "based_on": "", + "chart_name": "Notifications By Type", + "chart_type": "Group By", + "creation": "2025-09-08 12:07:04.576729", + "currency": "", + "docstatus": 0, + "doctype": "Dashboard Chart", + "document_type": "Notification Log", + "dynamic_filters_json": "[]", + "filters_json": "[]", + "group_by_based_on": "type", + "group_by_type": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "last_synced_on": "2026-01-12 00:01:03.245282", + "modified": "2026-01-12 00:02:23.444272", + "modified_by": "Administrator", + "module": "Core", + "name": "Notifications By Type", + "number_of_groups": 0, + "owner": "Administrator", + "parent_document_type": "", + "roles": [], + "show_values_over_chart": 0, + "source": "", + "time_interval": "Yearly", + "timeseries": 0, + "timespan": "Last Year", + "type": "Pie", + "use_report_chart": 0, + "value_based_on": "", + "y_axis": [] +} diff --git a/frappe/core/number_card/active_rq_worker/active_rq_worker.json b/frappe/core/number_card/active_rq_worker/active_rq_worker.json new file mode 100644 index 0000000000..0f5f4215e2 --- /dev/null +++ b/frappe/core/number_card/active_rq_worker/active_rq_worker.json @@ -0,0 +1,26 @@ +{ + "aggregate_function_based_on": "", + "creation": "2026-01-11 23:59:34.870238", + "currency": "INR", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "RQ Worker", + "dynamic_filters_json": "[]", + "filters_json": "[]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Active RQ Worker", + "modified": "2026-01-11 23:59:34.870238", + "modified_by": "Administrator", + "module": "Core", + "name": "Active RQ Worker", + "owner": "Administrator", + "parent_document_type": "", + "report_function": "Sum", + "show_full_number": 0, + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} diff --git a/frappe/core/number_card/error_logs/error_logs.json b/frappe/core/number_card/error_logs/error_logs.json new file mode 100644 index 0000000000..46d66cff65 --- /dev/null +++ b/frappe/core/number_card/error_logs/error_logs.json @@ -0,0 +1,27 @@ +{ + "aggregate_function_based_on": "", + "color": "#CB2929", + "creation": "2026-01-11 23:49:55.987084", + "currency": "INR", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Error Log", + "dynamic_filters_json": "[]", + "filters_json": "[]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Error Logs", + "modified": "2026-01-11 23:56:36.628717", + "modified_by": "Administrator", + "module": "Core", + "name": "Error Logs", + "owner": "Administrator", + "parent_document_type": "", + "report_function": "Sum", + "show_full_number": 0, + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} diff --git a/frappe/core/number_card/scheduled_jobs/scheduled_jobs.json b/frappe/core/number_card/scheduled_jobs/scheduled_jobs.json new file mode 100644 index 0000000000..d95d242cc2 --- /dev/null +++ b/frappe/core/number_card/scheduled_jobs/scheduled_jobs.json @@ -0,0 +1,26 @@ +{ + "aggregate_function_based_on": "", + "creation": "2026-01-11 23:55:30.429516", + "currency": "INR", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Scheduled Job Type", + "dynamic_filters_json": "[]", + "filters_json": "[]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Scheduled Jobs", + "modified": "2026-01-11 23:55:30.429516", + "modified_by": "Administrator", + "module": "Core", + "name": "Scheduled Jobs", + "owner": "Administrator", + "parent_document_type": "", + "report_function": "Sum", + "show_full_number": 0, + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} diff --git a/frappe/core/workspace/system/system.json b/frappe/core/workspace/system/system.json index 3358064534..441c19bb30 100644 --- a/frappe/core/workspace/system/system.json +++ b/frappe/core/workspace/system/system.json @@ -10,7 +10,7 @@ "label": "Notification Summary" } ], - "content": "[{\"id\":\"-bxX6Dwxxy\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Background Job Activity\",\"col\":12}},{\"id\":\"gccD2r7Ut3\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Notification Summary\",\"col\":12}}]", + "content": "[{\"id\":\"-bxX6Dwxxy\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Background Job Activity\",\"col\":12}},{\"id\":\"_U6-GCce9y\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Today's Error Count\",\"col\":4}},{\"id\":\"O8uXg3zzF1\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Scheduled Jobs\",\"col\":4}},{\"id\":\"i8x_VBbG5v\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Active Workers\",\"col\":4}},{\"id\":\"gccD2r7Ut3\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Notification Summary\",\"col\":12}}]", "creation": "2025-09-08 11:33:57.533875", "custom_blocks": [], "docstatus": 0, @@ -24,11 +24,24 @@ "label": "System", "link_type": "DocType", "links": [], - "modified": "2025-10-30 18:22:58.416219", + "modified": "2026-01-12 00:03:31.031145", "modified_by": "Administrator", "module": "Core", "name": "System", - "number_cards": [], + "number_cards": [ + { + "label": "Today's Error Count", + "number_card_name": "Error Logs" + }, + { + "label": "Scheduled Jobs", + "number_card_name": "Scheduled Jobs" + }, + { + "label": "Active Workers", + "number_card_name": "Active RQ Worker" + } + ], "owner": "Administrator", "parent_page": "", "public": 1, From 274d451b6292a4ef546f21f4ca6b1af083430ff8 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sun, 11 Jan 2026 23:57:39 +0530 Subject: [PATCH 179/401] feat(setup-wizard): toggle theme button --- frappe/public/js/frappe/ui/slides.js | 14 ++++++++++++++ frappe/public/scss/desk/slides.scss | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/frappe/public/js/frappe/ui/slides.js b/frappe/public/js/frappe/ui/slides.js index 1705dcdba5..761bc0030b 100644 --- a/frappe/public/js/frappe/ui/slides.js +++ b/frappe/public/js/frappe/ui/slides.js @@ -19,6 +19,8 @@ frappe.ui.Slide = class Slide { make() { if (this.before_load) this.before_load(this); + this.attach_toggle_theme_btn(); + this.$body = $(`

${__(this.title)}

@@ -48,6 +50,18 @@ frappe.ui.Slide = class Slide { this.made = true; } + attach_toggle_theme_btn() { + const toggle_icon = frappe.ui.get_current_theme() == "dark" ? "sun" : "moon"; + this.$toggle_theme_btn = + $(``).appendTo(this.$wrapper); + + this.$toggle_theme_btn.on("click", () => { + new frappe.ui.ThemeSwitcher().show(); + }); + } + refresh() { this.render_parent_dots(); if (!this.done) { diff --git a/frappe/public/scss/desk/slides.scss b/frappe/public/scss/desk/slides.scss index 98eca5f26a..67bde5217c 100644 --- a/frappe/public/scss/desk/slides.scss +++ b/frappe/public/scss/desk/slides.scss @@ -134,3 +134,9 @@ margin-bottom: var(--margin-lg); } } + +.toggle-theme-btn { + position: absolute; + top: var(--margin-lg); + right: var(--margin-lg); +} From f4936c15b8f99358683e7dc00279c2d1ad56b24d Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Mon, 12 Jan 2026 01:10:41 +0530 Subject: [PATCH 180/401] fix(setup-wizard): slide progress icon color for dark themes --- frappe/public/scss/desk/slides.scss | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frappe/public/scss/desk/slides.scss b/frappe/public/scss/desk/slides.scss index 98eca5f26a..62d069849b 100644 --- a/frappe/public/scss/desk/slides.scss +++ b/frappe/public/scss/desk/slides.scss @@ -10,14 +10,14 @@ height: 18px; width: 18px; border-radius: var(--border-radius-full); - border: 1px solid var(--gray-300); + border: 1px solid var(--ink-gray-3); margin: 0 var(--margin-xs); background-color: var(--card-bg); .slide-step-indicator { height: 6px; width: 6px; - background-color: var(--gray-300); + background-color: var(--ink-gray-3); border-radius: var(--border-radius-full); // display: none; } @@ -33,11 +33,11 @@ &.active { // background-color: var(--primary); - border: 1px solid var(--primary); + border: 1px solid var(--ink-gray-9); .slide-step-indicator { display: block; - background-color: var(--primary); + background-color: var(--ink-gray-9); } } @@ -52,8 +52,8 @@ // } &.step-success:not(.active) { - background-color: var(--primary); - border: 1px solid var(--primary); + background-color: var(--ink-gray-9); + border: 1px solid var(--ink-gray-9); .slide-step-indicator { display: none; @@ -64,7 +64,7 @@ .icon use { stroke-width: 2; - stroke: var(--white); + stroke: var(--ink-gray-1); } } } From 0f1e1c48af935e22fa6d9e3deb57d34715857c8a Mon Sep 17 00:00:00 2001 From: KerollesFathy Date: Sun, 11 Jan 2026 22:50:33 +0000 Subject: [PATCH 181/401] refactor: clear active filter on 'x' button click and remove redundant clear filter option --- frappe/public/js/frappe/list/list_filter.js | 22 +++++++-------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/frappe/public/js/frappe/list/list_filter.js b/frappe/public/js/frappe/list/list_filter.js index ff6e40a99b..6649134589 100644 --- a/frappe/public/js/frappe/list/list_filter.js +++ b/frappe/public/js/frappe/list/list_filter.js @@ -20,6 +20,13 @@ export default class ListFilter { [], __("Saved Filters") ); + + // Clear active filter on clicking 'x' button + const filter_x_btn = $(".filter-x-button"); + filter_x_btn.on("click", () => { + this.active_filter = null; + this.update_active_filter_label("Saved Filters"); + }); } render_saved_filters() { @@ -45,7 +52,6 @@ export default class ListFilter { }); this.append_create_new_item($menu); - this.append_clear_selected_filter($menu); } apply_saved_filter(filter_name, filter_label) { @@ -87,20 +93,6 @@ export default class ListFilter { }); $menu.append($create_item); } - append_clear_selected_filter($menu) { - const clear_filters = { - name: "clear_selected", - filter_name: "Clear Selected Filter", - }; - - const $clear_item = this.filter_template(clear_filters, true); - $clear_item.find(".filter-label").on("click", (e) => { - this.list_view.filter_area.clear(); - this.active_filter = null; - this.update_active_filter_label("Saved Filters"); - }); - $menu.append($clear_item); - } show_create_filter_dialog() { const fields = [ From 01e804a641b320ef450ae7a1c0138429b33a9586 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Mon, 12 Jan 2026 08:30:39 +0530 Subject: [PATCH 182/401] feat: add open sidebar option in toolbar --- frappe/public/js/frappe/form/toolbar.js | 44 ++++++++++++++++++ frappe/public/js/frappe/ui/page.js | 61 ------------------------- 2 files changed, 44 insertions(+), 61 deletions(-) diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index 1f65c44f30..d36fd811c1 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -352,6 +352,7 @@ frappe.ui.form.Toolbar = class Toolbar { // Print this.add_discard(); this.add_print(); + this.add_open_sidebar(); this.add_email(); this.add_rename(); this.add_reload(); @@ -484,6 +485,19 @@ frappe.ui.form.Toolbar = class Toolbar { } } + add_open_sidebar() { + if (this.page.hide_sidebar) { + return; + } + this.page.add_menu_item( + __("Open Sidebar"), + () => { + this.setup_sidebar_toggle(this.frm.sidebar.sidebar.parent()); + }, + true + ); + } + add_reload() { // reload this.page.add_menu_item( @@ -894,6 +908,36 @@ frappe.ui.form.Toolbar = class Toolbar { dialog.show(); } + setup_sidebar_toggle(sidebar_wrapper) { + console.log(sidebar_wrapper); + if (frappe.utils.is_xs() || frappe.utils.is_sm()) { + this.setup_overlay_sidebar(sidebar_wrapper); + } else { + sidebar_wrapper.toggle(); + } + $(document.body).trigger("toggleSidebar"); + } + + setup_overlay_sidebar(sidebar_wrapper) { + sidebar_wrapper.find(".close-sidebar").remove(); + let overlay_sidebar = sidebar_wrapper.find(".overlay-sidebar").addClass("opened"); + $('
').hide().appendTo(sidebar_wrapper).fadeIn(); + let scroll_container = $("html").css("overflow-y", "hidden"); + + sidebar_wrapper.find(".close-sidebar").on("click", (e) => this.close_sidebar(e)); + sidebar_wrapper.on("click", "button:not(.dropdown-toggle)", (e) => this.close_sidebar(e)); + + this.close_sidebar = () => { + scroll_container.css("overflow-y", ""); + sidebar_wrapper.find("div.close-sidebar").fadeOut(() => { + overlay_sidebar + .removeClass("opened") + .find(".dropdown-toggle") + .removeClass("text-muted"); + }); + }; + } + follow() { let is_followed = this.frm.get_docinfo().is_document_followed; frappe diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index b2ab3dc581..2a7c1be5fb 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -44,7 +44,6 @@ frappe.ui.Page = class Page { this.wrapper = $(this.parent); this.add_main_section(); this.setup_scroll_handler(); - this.setup_sidebar_toggle(); } setup_scroll_handler() { @@ -195,66 +194,6 @@ frappe.ui.Page = class Page { .appendTo(this.sidebar); } - setup_sidebar_toggle() { - let sidebar_toggle = $(".page-head").find(".sidebar-toggle-btn"); - let sidebar_wrapper = this.wrapper.find(".layout-side-section"); - if (this.disable_sidebar_toggle || !sidebar_wrapper.length) { - sidebar_toggle.last().remove(); - this.wrapper.addClass("no-list-sidebar"); - } else { - if (!frappe.is_mobile()) { - sidebar_toggle.attr("title", __("Toggle Sidebar")); - } - sidebar_toggle.attr("aria-label", __("Toggle Sidebar")); - sidebar_toggle.tooltip({ - delay: { show: 600, hide: 100 }, - trigger: "hover", - }); - sidebar_toggle.click(() => { - if (frappe.utils.is_xs() || frappe.utils.is_sm()) { - this.setup_overlay_sidebar(); - } else { - sidebar_wrapper.toggle(); - } - $(document.body).trigger("toggleSidebar"); - this.update_sidebar_icon(); - }); - } - } - - setup_overlay_sidebar() { - this.sidebar.find(".close-sidebar").remove(); - let overlay_sidebar = this.sidebar.find(".overlay-sidebar").addClass("opened"); - $('
').hide().appendTo(this.sidebar).fadeIn(); - let scroll_container = $("html").css("overflow-y", "hidden"); - - this.sidebar.find(".close-sidebar").on("click", (e) => this.close_sidebar(e)); - this.sidebar.on("click", "button:not(.dropdown-toggle)", (e) => this.close_sidebar(e)); - - this.close_sidebar = () => { - scroll_container.css("overflow-y", ""); - this.sidebar.find("div.close-sidebar").fadeOut(() => { - overlay_sidebar - .removeClass("opened") - .find(".dropdown-toggle") - .removeClass("text-muted"); - }); - }; - } - - update_sidebar_icon() { - let sidebar_toggle = $(".page-head").find(".sidebar-toggle-btn"); - let sidebar_toggle_icon = sidebar_toggle.find(".sidebar-toggle-icon"); - let sidebar_wrapper = this.wrapper.find(".layout-side-section"); - let is_sidebar_visible = $(sidebar_wrapper).is(":visible"); - sidebar_toggle_icon.html( - frappe.utils.icon( - is_sidebar_visible ? "es-line-sidebar-collapse" : "es-line-sidebar-expand", - "md" - ) - ); - } - set_indicator(label, color) { this.clear_indicator().removeClass("hide").html(`${label}`).addClass(color); } From 5c81a34cbb933c961767c3186b56a252ce4fb832 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 10:35:50 +0530 Subject: [PATCH 183/401] fix: remove orphan workspace sidebars --- frappe/model/sync.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 19568ca8e4..73cf16a616 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -201,13 +201,16 @@ def remove_orphan_doctypes(): def remove_orphan_entities(): entites = ["Workspace", "Dashboard", "Page", "Report"] + app_level_entities = ["Workspace Sidebar"] entity_filter_map = { "Workspace": {"public": 1}, "Page": {"standard": "Yes"}, "Report": {"is_standard": "Yes"}, "Dashboard": {"is_standard": True}, + "Workspace Sidebar": {"standard": True}, } entity_file_map = create_entity_file_map(entites) + for entity in entites: print(f"Removing orphan {entity}s") all_enitities = frappe.get_all( @@ -228,6 +231,26 @@ def remove_orphan_entities(): print(e) # save the deleted icons frappe.db.commit() # nosemgrep + # Remove app level entities + for app_entity in app_level_entities: + print(f"Removing orphan {app_entity}s") + all_enitities = frappe.get_all( + app_entity, filters=entity_filter_map.get(app_entity), fields=["name", "app"] + ) + for i, w in enumerate(all_enitities): + if w.app and not check_if_record_exists("app", frappe.get_app_path(w.app), app_entity, w.name): + try: + print(f"Deleting entity {app_entity} {w.name}") + frappe.delete_doc(app_entity, w.name, force=True, ignore_missing=True) + update_progress_bar(f"Deleting orphaned {app_entity}", i, len(all_enitities)) + print() + + except Exception as e: + print(f"Error occurred while deleting entity: {app_entity} {w.name}") + print(e) + + # save the deleted icons + frappe.db.commit() # nosemgrep def create_entity_file_map(entities): From cd6a28986e09dd6cb710d66681e2f07fd7d8f0b9 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 10:40:54 +0530 Subject: [PATCH 184/401] fix: delete associated desktop icon --- frappe/desk/doctype/workspace/workspace.py | 21 ------------------- .../workspace_sidebar/workspace_sidebar.py | 11 ++++++++++ frappe/desktop_icon/productivity.json | 21 ------------------- 3 files changed, 11 insertions(+), 42 deletions(-) delete mode 100644 frappe/desktop_icon/productivity.json diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index df25bd1910..c45b249cf7 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -127,8 +127,6 @@ class Workspace(Document): def on_trash(self): if self.public and not is_workspace_manager(): frappe.throw(_("You need to be Workspace Manager to delete a public workspace.")) - self.delete_desktop_icon() - self.delete_workspace_sidebar() self.delete_from_my_workspaces() def delete_from_my_workspaces(self): @@ -145,25 +143,6 @@ class Workspace(Document): if self.module and frappe.conf.developer_mode: delete_folder(self.module, "Workspace", self.title) - def delete_desktop_icon(self): - if self.public: - desktop_icon = frappe.get_all( - "Desktop Icon", - filters=[{"link_type": "Workspace Sidebar"}, {"link_to": self.name}], - limit=1, - pluck="name", - ) - if desktop_icon: - frappe.delete_doc("Desktop Icon", desktop_icon[0]) - - def delete_workspace_sidebar(self): - if self.public: - workspace_sidebar = frappe.get_all( - "Workspace Sidebar", filters=[{"name": self.name}], limit=1, pluck="name" - ) - if workspace_sidebar: - frappe.delete_doc("Workspace Sidebar", workspace_sidebar[0]) - @staticmethod def get_module_wise_workspaces(): workspaces = frappe.get_all( diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py index b54f0abbdd..d6390ca036 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py @@ -73,6 +73,7 @@ class WorkspaceSidebar(Document): if is_workspace_manager(): if frappe.conf.developer_mode and self.app: self.delete_file() + self.delete_desktop_icon() else: frappe.throw(_("You need to be Workspace Manager to delete a public workspace.")) @@ -136,6 +137,16 @@ class WorkspaceSidebar(Document): if counts and counts.most_common(1)[0]: return counts.most_common(1)[0][0] + def delete_desktop_icon(self): + desktop_icon = frappe.get_all( + "Desktop Icon", + filters=[{"link_type": "Workspace Sidebar"}, {"link_to": self.name}], + limit=1, + pluck="name", + ) + if desktop_icon: + frappe.delete_doc("Desktop Icon", desktop_icon[0]) + def get_allowed_modules(self): if not self.user.allow_modules: self.user.build_permissions() diff --git a/frappe/desktop_icon/productivity.json b/frappe/desktop_icon/productivity.json deleted file mode 100644 index 41c104514c..0000000000 --- a/frappe/desktop_icon/productivity.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "app": "frappe", - "creation": "2025-11-25 13:27:21.246918", - "docstatus": 0, - "doctype": "Desktop Icon", - "hidden": 0, - "icon": "folder-open", - "icon_type": "Link", - "idx": 0, - "label": "Productivity", - "link_to": "Productivity", - "link_type": "Workspace Sidebar", - "modified": "2026-01-01 20:07:01.152305", - "modified_by": "Administrator", - "name": "Productivity", - "owner": "Administrator", - "parent_icon": "Framework", - "restrict_removal": 0, - "roles": [], - "standard": 1 -} From c1513d0fce01b92b9f5509f4294df0dc4a71b27b Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Mon, 12 Jan 2026 11:17:11 +0530 Subject: [PATCH 185/401] feat: merge 2 nav's into one --- frappe/public/js/frappe/list/base_list.js | 5 +---- frappe/public/js/frappe/ui/page.html | 16 ++++++++++++++-- frappe/public/js/frappe/ui/page.js | 17 +++++++++++++++++ frappe/public/js/frappe/ui/toolbar/navbar.html | 18 +----------------- frappe/public/js/frappe/ui/toolbar/toolbar.js | 2 +- frappe/public/scss/desk/page.scss | 6 ++++++ 6 files changed, 40 insertions(+), 24 deletions(-) diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js index e48d81efa7..f28a1627ab 100644 --- a/frappe/public/js/frappe/list/base_list.js +++ b/frappe/public/js/frappe/list/base_list.js @@ -435,10 +435,7 @@ frappe.views.BaseList = class BaseList { this.$result[0].style.removeProperty("height"); // place it at the footer of the page - const resultContainerHeight = - window.innerHeight - - this.$result.get(0).offsetTop - - this.$paging_area.get(0).offsetHeight; + const resultContainerHeight = window.innerHeight - this.$paging_area.get(0).offsetHeight; this.$result.parent(".result-container").css({ height: resultContainerHeight - (frappe.is_mobile() ? 100 : 0) + "px", }); diff --git a/frappe/public/js/frappe/ui/page.html b/frappe/public/js/frappe/ui/page.html index 070103d0bd..486a02b36a 100644 --- a/frappe/public/js/frappe/ui/page.html +++ b/frappe/public/js/frappe/ui/page.html @@ -2,7 +2,7 @@
-
-
+
@@ -60,6 +60,18 @@
+ {% if frappe.is_mobile() %} + + {% endif %}
diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index 2a7c1be5fb..58d7e594b9 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -44,6 +44,15 @@ frappe.ui.Page = class Page { this.wrapper = $(this.parent); this.add_main_section(); this.setup_scroll_handler(); + this.setup_main_sidebar_toggle(); + this.setup_mobile_awesomebar(); + } + + setup_mobile_awesomebar() { + if (frappe.boot.desk_settings.search_bar && frappe.is_mobile()) { + let awesome_bar = new frappe.search.AwesomeBar(); + awesome_bar.setup(".navbar-modal-search-mobile"); + } } setup_scroll_handler() { @@ -223,6 +232,14 @@ frappe.ui.Page = class Page { return button; } + setup_main_sidebar_toggle() { + $(".sidebar-toggle-btn.navbar-brand").on("click", (event) => { + frappe.app.sidebar.set_height(); + frappe.app.sidebar.toggle_width(); + frappe.app.sidebar.prevent_scroll(); + }); + } + clear_indicator() { return this.indicator .removeClass() diff --git a/frappe/public/js/frappe/ui/toolbar/navbar.html b/frappe/public/js/frappe/ui/toolbar/navbar.html index 2fa9e8a326..bd09ee0774 100644 --- a/frappe/public/js/frappe/ui/toolbar/navbar.html +++ b/frappe/public/js/frappe/ui/toolbar/navbar.html @@ -1,10 +1,7 @@
- {% if (frappe.boot.read_only || frappe.boot.user.impersonated_by || frappe.is_mobile()) { %} + {% if (frappe.boot.read_only || frappe.boot.user.impersonated_by) { %} diff --git a/frappe/public/js/frappe/ui/toolbar/toolbar.js b/frappe/public/js/frappe/ui/toolbar/toolbar.js index 08506785a1..c8e652f952 100644 --- a/frappe/public/js/frappe/ui/toolbar/toolbar.js +++ b/frappe/public/js/frappe/ui/toolbar/toolbar.js @@ -33,7 +33,6 @@ frappe.ui.toolbar.Toolbar = class { this.bind_events(); $(document).trigger("toolbar_setup"); this.navbar = $(".navbar-brand"); - this.app_logo = this.navbar.find(".app-logo"); this.bind_click(); } change_toolbar() { @@ -54,6 +53,7 @@ frappe.ui.toolbar.Toolbar = class { frappe.app.sidebar.prevent_scroll(); }); } + bind_events() { // clear all custom menus on page change $(document).on("page-change", function () { diff --git a/frappe/public/scss/desk/page.scss b/frappe/public/scss/desk/page.scss index 86c3ad8039..eccaf6e5e3 100644 --- a/frappe/public/scss/desk/page.scss +++ b/frappe/public/scss/desk/page.scss @@ -1,3 +1,9 @@ +body:not([data-route^="Form"]) { + .layout-side-section { + display: none; + } +} + .page-title { display: flex; align-items: center; From c14e9d5d20525e2014dfcee8050dd3dcc6a281d5 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Fri, 9 Jan 2026 18:54:35 +0530 Subject: [PATCH 186/401] fix(response): set content-disposition header correctly again Broke in ee2c4c20ce82b56095acfbc91169fbf73b054b2b Signed-off-by: Akhil Narang --- frappe/utils/response.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/frappe/utils/response.py b/frappe/utils/response.py index b180b2cf3f..520d2a2837 100644 --- a/frappe/utils/response.py +++ b/frappe/utils/response.py @@ -298,10 +298,16 @@ def download_private_file(path: str) -> Response: return send_private_file(path.split("/private", 1)[1]) +FORCE_DOWNLOAD_EXTENSIONS = (".svg", ".html", ".htm", ".xml") + + def send_private_file(path: str) -> Response: path = os.path.join(frappe.local.conf.get("private_path", "private"), path.strip("/")) filename = os.path.basename(path) + extension = os.path.splitext(path)[1] + as_attachment = extension.lower() in FORCE_DOWNLOAD_EXTENSIONS + if frappe.local.request.headers.get("X-Use-X-Accel-Redirect"): path = "/protected/" + path response = Response() @@ -310,15 +316,14 @@ def send_private_file(path: str) -> Response: response.headers["Accept-Ranges"] = "bytes" response.headers["Content-Type"] = mimetypes.guess_type(filename)[0] or "application/octet-stream" + if as_attachment: + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{quote(filename)}" + else: filepath = frappe.utils.get_site_path(path) if not os.path.exists(filepath): raise NotFound - extension = os.path.splitext(path)[1] - blacklist = [".svg", ".html", ".htm", ".xml"] - as_attachment = extension.lower() in blacklist - response = werkzeug.utils.send_file( filepath, environ=frappe.local.request.environ, From e0e69a495c7a2640ae781c2f24cd80e4d110457c Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Fri, 9 Jan 2026 19:37:08 +0530 Subject: [PATCH 187/401] fix: force download files based on extension even on development server Signed-off-by: Akhil Narang --- frappe/middlewares.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frappe/middlewares.py b/frappe/middlewares.py index 1898734917..76feb5f96b 100644 --- a/frappe/middlewares.py +++ b/frappe/middlewares.py @@ -8,12 +8,27 @@ from werkzeug.middleware.shared_data import SharedDataMiddleware import frappe from frappe.utils import cstr, get_site_name +from frappe.utils.response import FORCE_DOWNLOAD_EXTENSIONS class StaticDataMiddleware(SharedDataMiddleware): def __call__(self, environ, start_response): self.environ = environ - return super().__call__(environ, start_response) + + def patch_start_response(status, headers, exc_info=None): + if ( + (path := environ.get("PATH_INFO", "")) + and path.startswith("/files/") + and path.lower().endswith(FORCE_DOWNLOAD_EXTENSIONS) + ): + from urllib.parse import quote + + filename = Path(path).name + headers.append(("Content-Disposition", f"attachment; filename*=UTF-8''{quote(filename)}")) + + return start_response(status, headers, exc_info) + + return super().__call__(environ, patch_start_response) def get_directory_loader(self, directory): def loader(path): From 59b440cb280b69e44c1739f894c6d3f1ea3900f6 Mon Sep 17 00:00:00 2001 From: Aarol D'Souza <98270103+AarDG10@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:06:28 +0530 Subject: [PATCH 188/401] fix(search): make QB DB-Aware when using Locate (#35796) * fix: make QB DB-Aware when choosing Locate * fix(test): adjust test to check smarter qb choice based on db --- frappe/query_builder/functions.py | 17 +++++++++++++++-- frappe/tests/test_query.py | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/frappe/query_builder/functions.py b/frappe/query_builder/functions.py index aaf0196542..98cd501be2 100644 --- a/frappe/query_builder/functions.py +++ b/frappe/query_builder/functions.py @@ -25,8 +25,21 @@ class Concat_ws(Function): class Locate(Function): - def __init__(self, *terms, **kwargs): - super().__init__("LOCATE", *terms, **kwargs) + def __init__(self, needle, haystack, **kwargs): + super().__init__("LOCATE", needle, haystack, **kwargs) + + +class Strpos(Function): + def __init__(self, needle, haystack, **kwargs): + super().__init__("STRPOS", haystack, needle, **kwargs) + + +class Instr(Function): + def __init__(self, needle, haystack, **kwargs): + super().__init__("INSTR", haystack, needle, **kwargs) + + +Locate = ImportMapper({db_type_is.MARIADB: Locate, db_type_is.POSTGRES: Strpos, db_type_is.SQLITE: Instr}) # for backward compatibility diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 83d2124d5d..86c627e490 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -1812,10 +1812,21 @@ class TestQuery(IntegrationTestCase): ], ) sql = query.get_sql() - self.assertIn( - self.normalize_sql("1/NULLIF(LOCATE('test',`name`),0) `relevance`"), - self.normalize_sql(sql), - ) + if frappe.db.db_type == "mariadb": + self.assertIn( + self.normalize_sql("1/NULLIF(LOCATE('test',`name`),0) `relevance`"), + self.normalize_sql(sql), + ) + elif frappe.db.db_type == "postgres": + self.assertIn( + self.normalize_sql("1/NULLIF(STRPOS(`name`,'test'),0) `relevance`"), + self.normalize_sql(sql), + ) + elif frappe.db.db_type == "sqlite": + self.assertIn( + self.normalize_sql("1/NULLIF(INSTR(`name`,'test'),0) `relevance`"), + self.normalize_sql(sql), + ) # Test multiple operators in fields query = frappe.qb.get_query( From 332013ddd15b13e57c1f4c8727eb531b8e318e7f Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 12 Jan 2026 12:17:50 +0530 Subject: [PATCH 189/401] fix: route to user on edit profile --- frappe/desk/page/desktop/desktop.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index 124766decc..7ca4b363ab 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -310,7 +310,7 @@ class DesktopPage { { icon: "edit", label: "Edit Profile", - url: `/update-profile/${frappe.session.user}`, + url: `/desk/user/${frappe.session.user}`, }, { icon: is_dark ? "sun" : "moon", From 431384f49fb9d6470d4223a50bab7092aedc0a15 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 12 Jan 2026 12:18:15 +0530 Subject: [PATCH 190/401] fix: remove reset password * users can edit profile and reset from there --- frappe/desk/page/desktop/desktop.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index 7ca4b363ab..d9c8a9d89f 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -319,11 +319,6 @@ class DesktopPage { new frappe.ui.ThemeSwitcher().show(); }, }, - { - icon: "lock", - label: "Reset Password", - url: "/update-password", - }, { icon: "rotate-ccw", label: "Reset to Default", From 9e1ccefb6da189c1dfe7d71d2b449a413aa13fa0 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 05:27:04 +0530 Subject: [PATCH 191/401] fix: bring back notifications and help on desktop --- frappe/desk/page/desktop/desktop.html | 34 ++++++++++++++- frappe/desk/page/desktop/desktop.js | 15 +++++++ .../frappe/ui/notifications/notifications.js | 17 +++++--- frappe/public/js/frappe/utils/utils.js | 43 +++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/frappe/desk/page/desktop/desktop.html b/frappe/desk/page/desktop/desktop.html index d084b62371..1228db8445 100644 --- a/frappe/desk/page/desktop/desktop.html +++ b/frappe/desk/page/desktop/desktop.html @@ -23,9 +23,41 @@
-
+
+
+ +
+ + +
+
+
+
diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index d9c8a9d89f..74f9d6e94d 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -214,6 +214,8 @@ class DesktopPage { setup() { this.setup_avatar(); + this.setup_help(); + this.setup_notifications(); this.setup_navbar(); this.setup_awesomebar(); this.setup_editing_mode(); @@ -303,6 +305,19 @@ class DesktopPage { me.stop_editing_layout("submit"); }); } + setup_notifications() { + this.notifications = new frappe.ui.Notifications({ + wrapper: $(".desktop-notifications"), + full_height: false, + }); + } + setup_help() { + frappe.ui.create_menu({ + parent: $(".help-dropdown"), + menu_items: frappe.utils.get_help_siblings(), + open_on_left: true, + }); + } setup_avatar() { $(".desktop-avatar").html(frappe.avatar(frappe.session.user, "avatar-medium")); let is_dark = document.documentElement.getAttribute("data-theme") === "dark"; diff --git a/frappe/public/js/frappe/ui/notifications/notifications.js b/frappe/public/js/frappe/ui/notifications/notifications.js index f2f83450fb..808b48b592 100644 --- a/frappe/public/js/frappe/ui/notifications/notifications.js +++ b/frappe/public/js/frappe/ui/notifications/notifications.js @@ -1,15 +1,17 @@ frappe.provide("frappe.search"); frappe.ui.Notifications = class Notifications { - constructor() { + constructor(opts) { this.tabs = {}; this.notification_settings = frappe.boot.notification_settings; + this.full_height = opts?.full_height || true; + this.wrapper = opts?.wrapper || $(".standard-items-sections"); this.make(); } make() { - $(".standard-items-sections").find(".sidebar-notification").removeClass("hidden"); - this.dropdown = $(".standard-items-sections").find(".dropdown-notifications"); + this.wrapper.find(".sidebar-notification").removeClass("hidden"); + this.dropdown = this.wrapper.find(".dropdown-notifications"); this.dropdown_list = this.dropdown.find(".notifications-list"); this.header_items = this.dropdown_list.find(".header-items"); this.header_actions = this.dropdown_list.find(".header-actions"); @@ -50,7 +52,9 @@ frappe.ui.Notifications = class Notifications { ${frappe.utils.icon("x")} `) .on("click", (e) => { - this.dropdown.addClass("hidden"); + if (!this.full_height) { + this.dropdown.addClass("hidden"); + } }) .appendTo(this.header_actions) .attr("title", __("Close")) @@ -130,6 +134,7 @@ frappe.ui.Notifications = class Notifications { setup_dropdown_events() { const dropdown = this.dropdown; + const full_height = this.full_height; this.dropdown.on("hide.bs.dropdown", (e) => { let hide = $(e.currentTarget).data("closable"); $(e.currentTarget).data("closable", true); @@ -146,7 +151,9 @@ frappe.ui.Notifications = class Notifications { const isInsideDropdown = $(e.target).closest(".notifications-list").length > 0; if (!isInsideNotificationBtn && !isInsideDropdown) { - dropdown.addClass("hidden"); + if (!full_height) { + dropdown.addClass("hidden"); + } } }); diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 20727e9577..4366b77f3f 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -2116,4 +2116,47 @@ Object.assign(frappe.utils, { } return frappe.user.has_role(["System Manager", "Administrator"]); }, + + get_help_siblings() { + const navbar_settings = frappe.boot.navbar_settings; + let help_dropdown_items = []; + + let custom_help_links = this.get_custom_help_links(); + + help_dropdown_items = custom_help_links.concat(help_dropdown_items); + + navbar_settings.help_dropdown.forEach((element) => { + let dropdown_children = { + name: element.name, + label: element.item_label, + }; + if (element.item_type === "Route") { + dropdown_children.url = element.route; + } + if (element.item_type === "Action") { + dropdown_children.onClick = function () { + frappe.utils.eval(element.action); + }; + } + help_dropdown_items.push(dropdown_children); + }); + + return help_dropdown_items; + }, + get_custom_help_links() { + let route = frappe.get_route_str(); + let breadcrumbs = route.split("/"); + + let links = []; + for (let i = 0; i < breadcrumbs.length; i++) { + let r = route.split("/", i + 1); + let key = r.join("/"); + let help_links = frappe.help.help_links[key] || []; + links = $.merge(links, help_links); + } + if (links.length) { + links.push({ is_divider: true }); + } + return links; + }, }); From 0887df7f983362bb56d8307300b4d8270afbd225 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 09:33:49 +0530 Subject: [PATCH 192/401] fix(desktop): dont show help on mobile --- frappe/desk/page/desktop/desktop.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/page/desktop/desktop.html b/frappe/desk/page/desktop/desktop.html index 1228db8445..95ed4316bf 100644 --- a/frappe/desk/page/desktop/desktop.html +++ b/frappe/desk/page/desktop/desktop.html @@ -52,7 +52,7 @@
- +
From 9f70908c25137dda8c259e43b53e6e6d9209b3c0 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 12:40:17 +0530 Subject: [PATCH 193/401] fix: remove help from desktop --- frappe/desk/page/desktop/desktop.html | 7 ++----- frappe/desk/page/desktop/desktop.js | 22 ++++++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/frappe/desk/page/desktop/desktop.html b/frappe/desk/page/desktop/desktop.html index 95ed4316bf..909a12e898 100644 --- a/frappe/desk/page/desktop/desktop.html +++ b/frappe/desk/page/desktop/desktop.html @@ -23,12 +23,12 @@
-
+
- - -
diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index 74f9d6e94d..a469ce2aad 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -214,7 +214,6 @@ class DesktopPage { setup() { this.setup_avatar(); - this.setup_help(); this.setup_notifications(); this.setup_navbar(); this.setup_awesomebar(); @@ -311,13 +310,6 @@ class DesktopPage { full_height: false, }); } - setup_help() { - frappe.ui.create_menu({ - parent: $(".help-dropdown"), - menu_items: frappe.utils.get_help_siblings(), - open_on_left: true, - }); - } setup_avatar() { $(".desktop-avatar").html(frappe.avatar(frappe.session.user, "avatar-medium")); let is_dark = document.documentElement.getAttribute("data-theme") === "dark"; @@ -334,6 +326,20 @@ class DesktopPage { new frappe.ui.ThemeSwitcher().show(); }, }, + { + icon: "info", + label: "About", + onClick: function () { + return frappe.ui.toolbar.show_about(); + }, + }, + { + icon: "support", + label: "Frappe Support", + onClick: function () { + window.open("https://support.frappe.io/help", "_blank"); + }, + }, { icon: "rotate-ccw", label: "Reset to Default", From 100ca7543e15e5b6e73397babbc6e6cf4b099577 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Mon, 12 Jan 2026 13:09:48 +0530 Subject: [PATCH 194/401] fix: spacing fixes on navbar --- frappe/public/js/frappe/ui/page.html | 90 ++++++++++++++-------------- frappe/public/scss/desk/mobile.scss | 6 +- frappe/public/scss/desk/navbar.scss | 4 +- frappe/public/scss/desk/page.scss | 1 + 4 files changed, 53 insertions(+), 48 deletions(-) diff --git a/frappe/public/js/frappe/ui/page.html b/frappe/public/js/frappe/ui/page.html index 486a02b36a..50680684d0 100644 --- a/frappe/public/js/frappe/ui/page.html +++ b/frappe/public/js/frappe/ui/page.html @@ -3,8 +3,8 @@
-
- -
- -
- - + +
+ - + + +
+
- -
- - -
-
- {% if frappe.is_mobile() %} - - {% endif %} diff --git a/frappe/public/scss/desk/mobile.scss b/frappe/public/scss/desk/mobile.scss index d130e5fb2f..d1dff7c4ad 100644 --- a/frappe/public/scss/desk/mobile.scss +++ b/frappe/public/scss/desk/mobile.scss @@ -331,8 +331,7 @@ body { margin-left: 5px; } .mobile-no-divider li a { - font-size: 14px; - padding-bottom: 5px; + line-height: 20px; } } } @@ -343,6 +342,9 @@ body { } } } + .standard-items-section .search-bar { + margin-right: 10px; + } } } diff --git a/frappe/public/scss/desk/navbar.scss b/frappe/public/scss/desk/navbar.scss index b2edecab6c..4b0ea766a6 100644 --- a/frappe/public/scss/desk/navbar.scss +++ b/frappe/public/scss/desk/navbar.scss @@ -50,7 +50,7 @@ .search-bar { max-width: 300px; svg { - stroke: var(--text-light); + stroke: var(--icon-stroke); margin-bottom: 2px; } .awesomplete { @@ -80,7 +80,7 @@ } .search-icon { background-color: var(--control-bg); - border-radius: 16px; + border-radius: 8px; padding: 6px; } } diff --git a/frappe/public/scss/desk/page.scss b/frappe/public/scss/desk/page.scss index eccaf6e5e3..35b451fd6f 100644 --- a/frappe/public/scss/desk/page.scss +++ b/frappe/public/scss/desk/page.scss @@ -124,6 +124,7 @@ body[data-route^="Form"] { .page-head-content { height: var(--page-head-height); padding: 8px 0px; + gap: 10px; } } From 421dc48610938f1b2d34efde764844a5aef0675f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 12 Jan 2026 13:14:39 +0530 Subject: [PATCH 195/401] fix: form indicators --- frappe/public/js/frappe/form/formatters.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index e2984e952a..b2e6b3a22a 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -433,7 +433,9 @@ frappe.format = function (value, df, options, doc) { df._options = doc ? doc[df.options] : null; } - var formatter = df.formatter || frappe.form.get_formatter(fieldtype); + var formatter = + frappe.meta.get_docfield(doc?.doctype, df.fieldname)?.formatter || + frappe.form.get_formatter(fieldtype); var formatted = formatter(value, df, options, doc); From 89f0713d10123f1800fc6595f13def40621a6c5b Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Mon, 12 Jan 2026 13:18:04 +0530 Subject: [PATCH 196/401] refactor: streamline address and contact rendering logic --- .../js/frappe/utils/address_and_contact.js | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/frappe/public/js/frappe/utils/address_and_contact.js b/frappe/public/js/frappe/utils/address_and_contact.js index 5e3217366f..73cfb1964f 100644 --- a/frappe/public/js/frappe/utils/address_and_contact.js +++ b/frappe/public/js/frappe/utils/address_and_contact.js @@ -2,27 +2,42 @@ frappe.provide("frappe.contacts"); $.extend(frappe.contacts, { clear_address_and_contact: function (frm) { - $(frm.fields_dict["address_html"].wrapper).html(""); - frm.fields_dict["contact_html"] && $(frm.fields_dict["contact_html"].wrapper).html(""); + for (const field of ["address_html", "contact_html"]) { + $(frm.fields_dict[field]?.wrapper)?.html(""); + } }, render_address_and_contact: function (frm) { - // render address - if (frm.fields_dict["address_html"] && "addr_list" in frm.doc.__onload) { - $(frm.fields_dict["address_html"].wrapper) - .html(frappe.render_template("address_list", frm.doc.__onload)) - .find(".btn-address") - .on("click", () => new_record("Address", frm)); - } + const items = [ + { + field: "address_html", + data: "addr_list", + template: "address_list", + btn: ".btn-address", + doctype: "Address", + }, + { + field: "contact_html", + data: "contact_list", + template: "contact_list", + btn: ".btn-contact", + doctype: "Contact", + }, + ]; - // render contact - if (frm.fields_dict["contact_html"] && "contact_list" in frm.doc.__onload) { - $(frm.fields_dict["contact_html"].wrapper) - .html(frappe.render_template("contact_list", frm.doc.__onload)) - .find(".btn-contact") - .on("click", () => new_record("Contact", frm)); + for (const item of items) { + // render address or contact + const field_wrapper = frm.fields_dict[item.field]?.wrapper; + + if (field_wrapper && frm.doc.__onload && item.data in frm.doc.__onload) { + $(field_wrapper) + .html(frappe.render_template(item.template, frm.doc.__onload)) + .find(item.btn) + .on("click", () => new_record(item.doctype, frm)); + } } }, + get_last_doc: function (frm) { const reverse_routes = frappe.route_history.slice().reverse(); const last_route = reverse_routes.find((route) => { @@ -38,6 +53,7 @@ $.extend(frappe.contacts, { docname, }; }, + get_address_display: function (frm, address_field, display_field) { if (frm.updating_party_details) { return; From 8505a8379b43b82d2fa97b529585959fa10992b2 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 14:18:29 +0530 Subject: [PATCH 197/401] chore: cleanup desktop settings doctype --- frappe/boot.py | 3 --- .../desktop_settings/desktop_settings.json | 20 +++---------------- .../desktop_settings/desktop_settings.py | 9 --------- frappe/desk/page/desktop/desktop.js | 5 +---- 4 files changed, 4 insertions(+), 33 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index 72888fb1a4..c1f572f995 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -161,9 +161,6 @@ def load_desktop_data(bootinfo): from frappe.desk.desktop import get_workspace_sidebar_items bootinfo.workspaces = get_workspace_sidebar_items() - bootinfo.show_app_icons_as_folder = frappe.db.get_single_value( - "Desktop Settings", "show_app_icons_as_folder" - ) bootinfo.workspace_sidebar_item = get_sidebar_items() allowed_pages = [d.name for d in bootinfo.workspaces.get("pages")] bootinfo.module_wise_workspaces = get_controller("Workspace").get_module_wise_workspaces() diff --git a/frappe/desk/doctype/desktop_settings/desktop_settings.json b/frappe/desk/doctype/desktop_settings/desktop_settings.json index 7a3b57e51e..20b7f56b67 100644 --- a/frappe/desk/doctype/desktop_settings/desktop_settings.json +++ b/frappe/desk/doctype/desktop_settings/desktop_settings.json @@ -5,36 +5,22 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "icon_style", - "navbar_style", - "show_app_icons_as_folder" + "icon_style" ], "fields": [ { - "default": "Subtle", + "default": "Solid", "fieldname": "icon_style", "fieldtype": "Select", "label": "Icon Style", "options": "Subtle\nSolid" - }, - { - "fieldname": "navbar_style", - "fieldtype": "Select", - "label": "Navbar Style", - "options": "Awesomebar\nmacOS Launchpad\nBrand Logo\nBrand Logo with Search\nTimeless Launchpad\nApps with Search" - }, - { - "default": "0", - "fieldname": "show_app_icons_as_folder", - "fieldtype": "Check", - "label": "Show App Icons As Folder" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-12-12 07:11:57.947973", + "modified": "2026-01-12 14:14:52.189474", "modified_by": "Administrator", "module": "Desk", "name": "Desktop Settings", diff --git a/frappe/desk/doctype/desktop_settings/desktop_settings.py b/frappe/desk/doctype/desktop_settings/desktop_settings.py index 9a89035911..6747b49162 100644 --- a/frappe/desk/doctype/desktop_settings/desktop_settings.py +++ b/frappe/desk/doctype/desktop_settings/desktop_settings.py @@ -15,15 +15,6 @@ class DesktopSettings(Document): from frappe.types import DF icon_style: DF.Literal["Subtle", "Solid"] - navbar_style: DF.Literal[ - "Awesomebar", - "macOS Launchpad", - "Brand Logo", - "Brand Logo with Search", - "Timeless Launchpad", - "Apps with Search", - ] - show_app_icons_as_folder: DF.Check # end: auto-generated types pass diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index a469ce2aad..0c94546af2 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -785,10 +785,7 @@ class DesktopIcon { } render_folder_thumbnail() { - let condition = - frappe.boot.show_app_icons_as_folder && - this.icon_type == "App" && - this.child_icons.length > 0; + let condition = this.icon_type == "App" && this.child_icons.length > 0; if (this.icon_type == "Folder" || condition) { if (!this.folder_wrapper) this.folder_wrapper = this.icon.find(".icon-container"); this.folder_wrapper.html(""); From a8950ad08628953121449099cdd3fd603f5401a9 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 14:20:59 +0530 Subject: [PATCH 198/401] chore: remove system workspace --- frappe/core/workspace/system/system.json | 54 ------------------------ 1 file changed, 54 deletions(-) delete mode 100644 frappe/core/workspace/system/system.json diff --git a/frappe/core/workspace/system/system.json b/frappe/core/workspace/system/system.json deleted file mode 100644 index 441c19bb30..0000000000 --- a/frappe/core/workspace/system/system.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "app": "frappe", - "charts": [ - { - "chart_name": "Background Job Activity", - "label": "Background Job Activity" - }, - { - "chart_name": "Notifications By Type", - "label": "Notification Summary" - } - ], - "content": "[{\"id\":\"-bxX6Dwxxy\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Background Job Activity\",\"col\":12}},{\"id\":\"_U6-GCce9y\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Today's Error Count\",\"col\":4}},{\"id\":\"O8uXg3zzF1\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Scheduled Jobs\",\"col\":4}},{\"id\":\"i8x_VBbG5v\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Active Workers\",\"col\":4}},{\"id\":\"gccD2r7Ut3\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Notification Summary\",\"col\":12}}]", - "creation": "2025-09-08 11:33:57.533875", - "custom_blocks": [], - "docstatus": 0, - "doctype": "Workspace", - "for_user": "", - "hide_custom": 0, - "icon": "monitor-check", - "idx": 0, - "indicator_color": "green", - "is_hidden": 0, - "label": "System", - "link_type": "DocType", - "links": [], - "modified": "2026-01-12 00:03:31.031145", - "modified_by": "Administrator", - "module": "Core", - "name": "System", - "number_cards": [ - { - "label": "Today's Error Count", - "number_card_name": "Error Logs" - }, - { - "label": "Scheduled Jobs", - "number_card_name": "Scheduled Jobs" - }, - { - "label": "Active Workers", - "number_card_name": "Active RQ Worker" - } - ], - "owner": "Administrator", - "parent_page": "", - "public": 1, - "quick_lists": [], - "roles": [], - "sequence_id": 27.0, - "shortcuts": [], - "title": "System", - "type": "Workspace" -} From 612457ed793ebdead62b130fce751cbdf17ef4d5 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 12 Jan 2026 14:29:41 +0530 Subject: [PATCH 199/401] fix: show system health report as first link --- frappe/workspace_sidebar/system.json | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/frappe/workspace_sidebar/system.json b/frappe/workspace_sidebar/system.json index cda3ea38c6..16793a95ef 100644 --- a/frappe/workspace_sidebar/system.json +++ b/frappe/workspace_sidebar/system.json @@ -6,18 +6,6 @@ "header_icon": "monitor-check", "idx": 0, "items": [ - { - "child": 0, - "collapsible": 1, - "icon": "home", - "indent": 0, - "keep_closed": 0, - "label": "Home", - "link_to": "System", - "link_type": "Workspace", - "show_arrow": 0, - "type": "Link" - }, { "child": 0, "collapsible": 1, @@ -226,7 +214,7 @@ "type": "Link" } ], - "modified": "2026-01-08 14:16:38.658526", + "modified": "2026-01-12 14:27:13.391611", "modified_by": "Administrator", "module": "Core", "name": "System", From d80be8a1524b7704b7709802b584d65211794a82 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Mon, 12 Jan 2026 14:30:23 +0530 Subject: [PATCH 200/401] fix: align all contents to left on sidebar --- .../js/frappe/form/templates/form_sidebar.html | 15 ++++----------- frappe/public/scss/desk/form_sidebar.scss | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/frappe/public/js/frappe/form/templates/form_sidebar.html b/frappe/public/js/frappe/form/templates/form_sidebar.html index 9ba4eab1ae..7db10aa603 100644 --- a/frappe/public/js/frappe/form/templates/form_sidebar.html +++ b/frappe/public/js/frappe/form/templates/form_sidebar.html @@ -1,6 +1,6 @@