From d97fdfe20ae54423a69f3e63cd65c2b6daf284a8 Mon Sep 17 00:00:00 2001 From: trustedcomputer Date: Sun, 15 Feb 2026 09:42:54 -0800 Subject: [PATCH 01/50] fix: exclude print_format_builder print formats from weasyprint processing in email-attached PDFs --- frappe/utils/print_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/utils/print_utils.py b/frappe/utils/print_utils.py index e2a4aabbd8..ede056cc0c 100644 --- a/frappe/utils/print_utils.py +++ b/frappe/utils/print_utils.py @@ -138,7 +138,9 @@ def attach_print( if print_format and print_format != "Standard": print_format_doc = frappe.get_cached_doc("Print Format", print_format) is_weasyprint_print_format = not ( - print_format_doc.custom_format or print_format_doc.get("print_designer_print_format") + print_format_doc.custom_format + or print_format_doc.print_format_builder + or print_format_doc.get("print_designer_print_format") ) with print_language(lang or frappe.local.lang): From 849a935e20ec330a788d9ea313fce6094879ca29 Mon Sep 17 00:00:00 2001 From: Harsh Patadia <142822496+harshp4114@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:14:09 +0530 Subject: [PATCH 02/50] fix: List Settings UI Issue (#36903) * fix: List Settings UI Issue #36861 * fix: removed extra padding from top, made everything vertically aligned * fix: properly formatted the changes made to the file * fix: failing linter * fix: failing linter * fix: failing linter * fix: failing linter * fix: failing linter * fix: formatting * refactor: code cleanup --------- Co-authored-by: priyanshshah2442 Co-authored-by: Ejaaz Khan --- frappe/public/js/frappe/list/list_settings.js | 23 +++++++++++-------- frappe/public/scss/desk/list.scss | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/frappe/public/js/frappe/list/list_settings.js b/frappe/public/js/frappe/list/list_settings.js index c712f14fdc..7e5d443866 100644 --- a/frappe/public/js/frappe/list/list_settings.js +++ b/frappe/public/js/frappe/list/list_settings.js @@ -107,22 +107,27 @@ export default class ListSettings { } let is_sortable = idx == 0 ? `` : `sortable`; let show_sortable_handle = idx == 0 ? `hide` : ``; - let can_remove = idx == 0 || is_status_field(me.fields[idx]) ? `hide` : ``; + let can_remove = idx == 0 || is_status_field(me.fields[idx]) ? `hide` : `d-flex`; fields += ` -
+
-
-
+
+
${frappe.utils.icon("drag", "xs", "", "", "sortable-handle " + show_sortable_handle)}
-
+ +
${__(me.fields[idx].label, null, me.doctype)}
-
- + + diff --git a/frappe/public/scss/desk/list.scss b/frappe/public/scss/desk/list.scss index 95db3eee9b..6ac02526f5 100644 --- a/frappe/public/scss/desk/list.scss +++ b/frappe/public/scss/desk/list.scss @@ -493,7 +493,7 @@ input.list-header-checkbox { .filter-section { display: flex; - padding: 0 var(--padding-xs); + padding: 0; } .filter-selector .btn-group { From 5e92584bf54d80134749775ed5f786a509016156 Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Wed, 18 Feb 2026 14:18:34 +0530 Subject: [PATCH 03/50] fix: shadow inset issue in grid links --- frappe/public/scss/common/grid.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss index 8ab4c34812..e4bf350ec9 100644 --- a/frappe/public/scss/common/grid.scss +++ b/frappe/public/scss/common/grid.scss @@ -313,6 +313,8 @@ .link-btn { background-color: var(--bg-color); + height: calc(100% - 4px); + margin-top: 2px; } .form-control:focus { From 9c0acda79a6c1bd35929e2ba594e1e4d7ad8c766 Mon Sep 17 00:00:00 2001 From: Patel Aasif Khan <159230804+aasif-patel@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:33:13 +0530 Subject: [PATCH 04/50] fix: Fixed Email Header folding issue with Message-ID (#35266) * fix: Fixed Email Header folding issue with Message-ID * fix: email header folding with smtp policy refold * chore: linter --------- Co-authored-by: s-aga-r --- frappe/email/doctype/email_queue/email_queue.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 738125799c..09d3b2d6ad 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -306,7 +306,8 @@ class SendMailContext: recipient.update_db(status="Sent", commit=True) def get_message_object(self, message): - return Parser(policy=SMTP).parsestr(message) + policy = SMTP.clone(refold_source="none") + return Parser(policy=policy).parsestr(message) def message_placeholder(self, placeholder_key): # sourcery skip: avoid-builtin-shadow From 13c7a38fce0e8934d7220ef6e2e33bb80f60e7fa Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 18 Feb 2026 15:20:48 +0530 Subject: [PATCH 05/50] fix: link received reply of sent email (#37177) --- frappe/email/receive.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 08157dc5f6..c962e71c82 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -799,15 +799,25 @@ class InboundMail(Email): return self._reference_document reference_document = "" - parent = self.parent_email_queue() or self.parent_communication() + parent_email_queue = self.parent_email_queue() + parent_communication = self.parent_communication() - if parent and parent.reference_doctype: + parent = None + if parent_email_queue and parent_email_queue.reference_doctype: + parent = parent_email_queue + elif parent_communication and parent_communication.reference_doctype: + parent = parent_communication + + if parent: reference_doctype, reference_name = parent.reference_doctype, parent.reference_name reference_document = self.get_doc(reference_doctype, reference_name, ignore_error=True) if not reference_document and self.email_account.append_to: reference_document = self.match_record_by_subject_and_sender(self.email_account.append_to) + if not reference_document and self.is_reply_to_system_sent_mail(): + reference_document = parent_communication + self._reference_document = reference_document or "" return self._reference_document From 3e061b026be45e0dfc3a0d16aa3c4d3f13245de6 Mon Sep 17 00:00:00 2001 From: Prathamesh Kurunkar <59260326+prathameshkurunkar7@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:55:06 +0530 Subject: [PATCH 06/50] fix(email): ensure CC header visibility according to email semantics (#37182) * fix(email): ensure CC header visibility according to email semantics * chore(email): fix linting in docs --- frappe/email/__init__.py | 56 ++++++++++++++++++++------------------ frappe/email/email_body.py | 3 +- frappe/tests/test_email.py | 50 ++++++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 30 deletions(-) diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index 5871a9f562..7dc2ce4478 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -158,33 +158,35 @@ def sendmail( """Send email using user's default **Email Account** or global default **Email Account**. - :param recipients: List of recipients. - :param sender: Email sender. Default is current user or default outgoing account. - :param subject: Email Subject. - :param message: (or `content`) Email Content. - :param as_markdown: Convert content markdown to HTML. - :param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true - :param send_priority: Priority for Email Queue, default 1. - :param reference_doctype: (or `doctype`) Append as communication to this DocType. - :param reference_name: (or `name`) Append as communication to this document name. - :param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe` - :param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict). - :param attachments: List of attachments. - :param reply_to: Reply-To Email Address. - :param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email. - :param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To. - :param send_after: Send after the given datetime. - :param expose_recipients: Display all recipients in the footer message - "This email was sent to" - :param communication: Communication link to be set in Email Queue record - :param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id - :param template: Name of html template from templates/emails folder - :param args: Arguments for rendering the template - :param header: Append header in email - :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 - :param add_css: Whether to add CSS from hooks/email_css to the email template + :param recipients: List of recipients. + :param sender: Email sender. Default is current user or default outgoing account. + :param subject: Email Subject. + :param message: (or `content`) Email Content. + :param as_markdown: Convert content markdown to HTML. + :param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true + :param send_priority: Priority for Email Queue, default 1. + :param reference_doctype: (or `doctype`) Append as communication to this DocType. + :param reference_name: (or `name`) Append as communication to this document name. + :param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe` + :param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict). + :param attachments: List of attachments. + :param reply_to: Reply-To Email Address. + :param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email. + :param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To. + :param send_after: Send after the given datetime. + :param expose_recipients: Controls recipient visibility. "header" shows all TO recipients in the To header. + "footer" adds "This email was sent to..." text in footer. None (default) hides TO recipients from each other. + Note: CC header is always visible regardless of this setting (as per email semantics). + :param communication: Communication link to be set in Email Queue record + :param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id + :param template: Name of html template from templates/emails folder + :param args: Arguments for rendering the template + :param header: Append header in email + :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 + :param add_css: Whether to add CSS from hooks/email_css to the email template """ from frappe.utils.jinja import get_email_from_template diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 2c2d8b5a6f..5c1fe6320b 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -337,7 +337,8 @@ class EMail: "To": ", ".join(self.recipients) if self.expose_recipients == "header" else "", "Date": email.utils.formatdate(), "Reply-To": self.reply_to if self.reply_to else None, - "CC": ", ".join(self.cc) if self.cc and self.expose_recipients == "header" else None, + # cc should always be visible - as that is the semantic meaning of cc, this should not be dependent on expose_recipients + "CC": ", ".join(self.cc) if self.cc else None, "X-Frappe-Site": get_url(), } diff --git a/frappe/tests/test_email.py b/frappe/tests/test_email.py index dedb3ab5b0..7e00b4c3d0 100644 --- a/frappe/tests/test_email.py +++ b/frappe/tests/test_email.py @@ -82,8 +82,53 @@ class TestEmail(IntegrationTestCase): self.assertEqual(len(queue_recipients), 2) self.assertTrue("Unsubscribe" in frappe.safe_decode(frappe.flags.sent_mail)) - def test_cc_header(self): - # test if sending with cc's makes it into header + def test_cc_header_always_visible(self): + """Test that CC header is always visible regardless of expose_recipients setting. + + CC (Carbon Copy) should always be visible to all recipients as per email semantics. + This enables 'Reply All' functionality. If sender wants hidden recipients, they should use BCC. + """ + frappe.sendmail( + recipients=["test@example.com"], + cc=["test1@example.com"], + sender="admin@example.com", + reference_doctype="User", + reference_name="Administrator", + subject="Testing CC Header Visibility", + message="CC should be visible without expose_recipients", + unsubscribe_message="Unsubscribe", + # No expose_recipients set - CC should still be visible + ) + email_queue = frappe.db.sql( + """select name from `tabEmail Queue` where status='Not Sent'""", as_dict=1 + ) + self.assertEqual(len(email_queue), 1) + queue_recipients = [ + r.recipient + for r in frappe.db.sql( + """select recipient from `tabEmail Queue Recipient` + where status='Not Sent'""", + as_dict=1, + ) + ] + self.assertTrue("test@example.com" in queue_recipients) + self.assertTrue("test1@example.com" in queue_recipients) + + message = frappe.db.sql( + """select message from `tabEmail Queue` + where status='Not Sent'""", + as_dict=1, + )[0].message + # CC should be visible even without expose_recipients + self.assertTrue("CC: test1@example.com" in message) + # TO should use placeholder (hidden) when expose_recipients is not set + self.assertTrue("To: " in message) + + def test_cc_header_with_expose_recipients(self): + """Test CC and TO visibility when expose_recipients='header' is set. + + With expose_recipients='header', both TO and CC should be visible in headers. + """ frappe.sendmail( recipients=["test@example.com"], cc=["test1@example.com"], @@ -115,6 +160,7 @@ class TestEmail(IntegrationTestCase): where status='Not Sent'""", as_dict=1, )[0].message + # Both TO and CC should be visible with expose_recipients="header" self.assertTrue("To: test@example.com" in message) self.assertTrue("CC: test1@example.com" in message) From 79003d6674a50d7b186b823bde5def2b055f8cf3 Mon Sep 17 00:00:00 2001 From: Prathamesh Kurunkar <59260326+prathameshkurunkar7@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:57:20 +0530 Subject: [PATCH 07/50] docs(sendmail): clarify behavior of queue_separately and CC/BCC in email_queue (#37113) * fix(sendmail): enhance queuing of cc and bcc recipients to avoid duplicates * revert: fix(sendmail): enhance queuing of cc and bcc recipients to avoid duplicates This reverts commit 66c0c1cfb7c0f46f5687ce5266f945e88dadc1db. * docs(email_queue): clarify behavior of queue_separately and CC/BCC in email queue --- frappe/email/doctype/email_queue/email_queue.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 09d3b2d6ad..f5cbc66922 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -540,7 +540,11 @@ class QueueBuilder: :param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To. :param send_after: Send this email after the given datetime. If value is in integer, then `send_after` will be the automatically set to no of days from current date. :param communication: Communication link to be set in Email Queue record - :param queue_separately: Queue each email separately + :param queue_separately: Queue each email separately (one per recipient). When True, each TO recipient + receives an individual email. Note: If CC/BCC are provided with queue_separately=True, CC/BCC + recipients will receive one email for each TO recipient(duplicates), as each TO email is a separate message + that includes CC/BCC. To avoid this, either don't use queue_separately, or add CC/BCC recipients + to the recipients list instead. :param is_notification: Marks email as notification so will not trigger notifications from system :param add_unsubscribe_link: Send unsubscribe link in the footer of the Email, default 1. :param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id @@ -796,6 +800,15 @@ class QueueBuilder: ) def send_emails(self, queue_data, final_recipients): + """ + Send emails to recipients separately. + + Note: CC/BCC recipients are included in each email sent to TO recipients. + This means CC/BCC will receive one email per TO recipient. This is expected + behavior because queue_separately creates individual emails for each TO + recipient, and CC/BCC are copied on each individual email. + + """ # This is used to bulk send emails from same sender to multiple recipients separately # This re-uses smtp server instance to minimize the cost of new session creation frappe_mail_client = None From 081908540dc2b8f63e97d390b4f0212dc0c87007 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Wed, 18 Feb 2026 16:08:12 +0530 Subject: [PATCH 08/50] feat: add FC billing banner on Desktop --- frappe/public/js/billing.bundle.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/billing.bundle.js b/frappe/public/js/billing.bundle.js index 89c9a9d3a7..42c43a780c 100644 --- a/frappe/public/js/billing.bundle.js +++ b/frappe/public/js/billing.bundle.js @@ -24,6 +24,7 @@ $(document).ready(function () { generateTrialSubscriptionBanner(response.trial_end_date) ); } + addManageTrialBannerDesktop(response.trial_end_date); } addManageBillingDropdown(); @@ -39,13 +40,28 @@ function setErrorMessage(message) { $("#fc-login-error").text(message); } +function addManageTrialBannerDesktop(trial_end_date) { + $(document).on("desktop_screen", function (event, data) { + const icons_container = data.desktop.wrapper.find(".icons-container").first(); + + $(".desktop-container").before( + generateTrialSubscriptionBanner(trial_end_date).css({ + width: icons_container.width(), + margin: "auto", + padding: "20px 20px 0px", + }) + ); + icons_container.css("margin-top", "40px"); + }); +} + function addManageBillingDropdown() { $(document).on("desktop_screen", function (event, data) { data.desktop.add_menu_item({ label: __("Manage Billing"), icon: "receipt-text", condition: function () { - return frappe.boot.sysdefaults.demo_company; + return frappe.boot.is_fc_site; }, onClick: function () { return openFrappeCloudDashboard(); @@ -65,7 +81,7 @@ function generateTrialSubscriptionBanner(trialEndDate) { const trial_end_string = trial_end_days > 1 ? `${trial_end_days} days` : `${trial_end_days} day`; - return $(` + return $(`
-
-
- - - - - - - - - - -
- - Your trial ends in ${trial_end_string}. - - - ${ - isFCUser - ? "Please upgrade for uninterrupted services" - : "Please contact your system administrator to upgrade your plan." - } - -
-
- ${ - isFCUser - ? `` - : "" - } -
-
`); + const banner_message = isFCUser + ? "Please upgrade for uninterrupted services" + : "Please contact your system administrator to upgrade your plan."; + let card_args = { + title: `Your trial ends in ${trial_end_string}`, + message: banner_message, + outline: true, + close_button: true, + popper: true, + primary_button_alignment: "right", + }; + isFCUser = true; + if (isFCUser) { + card_args.primary_action_label = "Upgrade"; + card_args.primary_action_suffix_icon = "square-arrow-out-up-right"; + card_args.styles = { + "sidebar-card-button-bg-color": "var(--surface-gray-2)", + "sidebar-card-button-color": "var(--ink-gray-7)", + "sidebar-card-button-outline": "var(--ink-gray-7)", + }; + } + $(document).on("desktop_screen", function (event, data) { + if ( + frappe.boot.is_fc_site && + !!frappe.boot.setup_complete && + !frappe.is_mobile() && + frappe.user.has_role("System Manager") + ) { + if (response.trial_end_date && trial_end_date > new Date()) { + card_args.parent = $(".icons-container").first(); + let banner_card = new frappe.ui.SidebarCard(card_args); + } + addManageBillingDropdown(data.desktop); + + $(".login-to-fc, .upgrade-plan-button").on("click", function () { + openFrappeCloudDashboard(); + }); + } + }); + $(document).on("sidebar_setup", function (event, data) { + let sidebar = data.sidebar; + // card_args.close_button = null; + sidebar.add_card({ + title: card_args.title, + icon: "info", + message: card_args.message, + // primary_action_icon: "zap", + // primary_action_label: "Upgrade", + // primary_button_width: "full", + // primary_action: () => {}, + }); + }); +}); + +function setErrorMessage(message) { + $("#fc-login-error").text(message); +} + +function addManageBillingDropdown(desktop) { + desktop.add_menu_item({ + label: __("Manage Billing"), + icon: "receipt-text", + condition: function () { + return frappe.boot.is_fc_site; + }, + onClick: function () { + return openFrappeCloudDashboard(); + }, + }); +} +function openFrappeCloudDashboard() { + window.open( + `${frappeCloudBaseEndpoint}/dashboard/sites/${frappe.boot.site_info.name}`, + "_blank" + ); } diff --git a/frappe/public/js/frappe/form/info_card.js b/frappe/public/js/frappe/form/info_card.js index 349af9c298..f41a7d70ce 100644 --- a/frappe/public/js/frappe/form/info_card.js +++ b/frappe/public/js/frappe/form/info_card.js @@ -35,6 +35,7 @@ export class InfoCard { trigger: $(this.label_span).find("svg").get(0), close_button: true, popper: true, + primary_button_width: "full", }; if (this.df.documentation_url) { card_args.primary_action_label = "Read More"; diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.js b/frappe/public/js/frappe/ui/sidebar/sidebar.js index 8697c7446b..65b373a58a 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.js @@ -99,6 +99,7 @@ frappe.ui.Sidebar = class Sidebar { this.workspace_sidebar_items = updated_items; } setup(workspace_title) { + $(document).trigger("sidebar_setup", { sidebar: this }); this.sidebar_title = workspace_title; this.check_for_private_workspace(workspace_title); this.workspace_title = this.sidebar_title.toLowerCase(); @@ -110,11 +111,9 @@ frappe.ui.Sidebar = class Sidebar { this.add_sidebar_cards(); } add_card(card) { - if ( - this.desktop_menu_items && - this.desktop_menu_items.find((i) => i.to_title_case === card.title) - ) - return; + if (this.cards && this.cards.find((i) => i.title === card.title)) return; + card.parent = this.wrapper.find(".body-sidebar-cards"); + delete card.styles; this.cards.push(card); } add_sidebar_cards() { diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_card.html b/frappe/public/js/frappe/ui/sidebar/sidebar_card.html index e3bcca3f7c..69a4551764 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_card.html +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_card.html @@ -10,15 +10,16 @@ {% } else { %}
{%= frappe.utils.icon(card.icon, "sm", "", "", "card-icon") %} - + - {%= frappe.utils.icon("x","sm", "", "", "card-icon cursor-pointer") %} + {%= frappe.utils.icon("x","sm", "", "", "card-icon cursor-pointer close-button") %}
{% } %} - {% if(card.primary_action_label) { %} - {% } %} +
\ No newline at end of file diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_card.js b/frappe/public/js/frappe/ui/sidebar/sidebar_card.js index 6ba32973f0..c6ce41de75 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_card.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_card.js @@ -5,6 +5,10 @@ frappe.provide("frappe.ui"); frappe.ui.SidebarCard = class SidebarCard { constructor(opts) { Object.assign(this, opts); + this.alignment_style_map = { + right: "flex-end", + left: "flex-start", + }; this.make(opts); this.setup(); this.display = false; @@ -31,10 +35,16 @@ frappe.ui.SidebarCard = class SidebarCard { ], }); } + if (this.outline) { + this.card.addClass("card-outline"); + this.card.removeClass("px-2 py-2"); + } this.card.prependTo(this.parent); + this.set_button_alignment(); } setup() { this.setup_primary_action(); + this.setup_close_button(); } toggle() { if (this.display) { @@ -59,6 +69,14 @@ frappe.ui.SidebarCard = class SidebarCard { me.primary_action(event); }); } + setup_close_button() { + const me = this; + if (this.close_button) { + this.card.find(".close-button").on("click", function () { + me.toggle(); + }); + } + } set_styles() { if (this.styles) { const $root = $(":root"); @@ -67,4 +85,11 @@ frappe.ui.SidebarCard = class SidebarCard { } } } + set_button_alignment() { + if (this.primary_button_alignment) { + this.card + .find(".sidebar-card-actions") + .css("justifyContent", this.alignment_style_map[this.primary_button_alignment]); + } + } }; diff --git a/frappe/public/scss/desk/sidebar_card.scss b/frappe/public/scss/desk/sidebar_card.scss index 56144f1754..d64bc5f828 100644 --- a/frappe/public/scss/desk/sidebar_card.scss +++ b/frappe/public/scss/desk/sidebar_card.scss @@ -1,6 +1,6 @@ :root { --sidebar-card-button-outline: var(--surface-blue-3); - --sidebar-card-button-bg-color: var(var(--surface-blue-2)); + --sidebar-card-button-bg-color: var(--surface-blue-2); --sidebar-card-button-color: var(--ink-blue-3); } .card-title-container { @@ -54,3 +54,11 @@ .cursor-pointer { cursor: pointer; } + +.card-outline { + border: 1px solid; + box-shadow: none; + border-color: var(--outline-gray-2, #e2e2e2); + border-radius: calc(var(--border-radius-lg) + 2px); + padding: calc(var(--padding-md) - 3px); +} From 6a132e94e0de79860187bebd27d6c2323b2b1ef6 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 19 Feb 2026 13:10:37 +0530 Subject: [PATCH 31/50] fix(Email Account): remove redundant field (#37229) --- frappe/email/doctype/email_account/email_account.json | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 042366b1cf..08d661314d 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -65,7 +65,6 @@ "always_use_account_email_id_as_sender", "always_use_account_name_as_sender_name", "send_unsubscribe_message", - "add_x_original_from", "track_email_status", "headers_section", "column_break_mcbu", From 15bcd7d2090f8d36f28a01815a0a16c980ff71cd Mon Sep 17 00:00:00 2001 From: Vibhuti Garachh <157696107+Vibhuti410@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:10:22 +0530 Subject: [PATCH 32/50] fix: remove height override causing layout issue after save (#37236) Co-authored-by: Frappe --- frappe/public/scss/common/grid.scss | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss index e4bf350ec9..329bee0758 100644 --- a/frappe/public/scss/common/grid.scss +++ b/frappe/public/scss/common/grid.scss @@ -383,12 +383,7 @@ line-height: 1.3 !important; } } - .data-row { - div[data-fieldname="options"], - div[data-fieldtype="Text Editor"] { - height: auto; - } - } + .grid-static-col { background-color: var(--fg-color); &.sticky-grid-col { From 06abc3e5acfd4a0b78d226004f74b0777f52a23b Mon Sep 17 00:00:00 2001 From: sokumon Date: Thu, 19 Feb 2026 14:10:27 +0530 Subject: [PATCH 33/50] fix: render it correctly --- frappe/desk/page/desktop/desktop.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index 36e4633855..2249cc05c5 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -176,8 +176,7 @@ class DesktopPage { this.desktop_menu_items = []; } update() { - this.make(this.page); - this.setup(); + this.make(); } prepare() { this.apps_icons = []; @@ -277,8 +276,8 @@ class DesktopPage { if (this.edit_mode) { this.start_editing_layout(); } + this.setup(); } - setup() { $(document).trigger("desktop_screen", { desktop: this }); this.setup_avatar(); From 6c55d6a9a058c9e9eabe47db57d550414b9b2db7 Mon Sep 17 00:00:00 2001 From: Frappe Date: Thu, 19 Feb 2026 14:21:10 +0530 Subject: [PATCH 34/50] fix: add z-index back to fix sticky scroll behavior --- frappe/public/scss/common/grid.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss index 9730850b2d..8fab01ab05 100644 --- a/frappe/public/scss/common/grid.scss +++ b/frappe/public/scss/common/grid.scss @@ -68,6 +68,7 @@ .row-index { position: sticky; left: 0; + z-index: 1; } .row-index { left: 31px; @@ -140,6 +141,7 @@ position: sticky; left: 0; background-color: var(--fg-color); + z-index: 1; } .row-index { left: 31px; From 7e0be7f170d7a378fdf7ec34e577a5635368c7dc Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 19 Feb 2026 14:32:07 +0530 Subject: [PATCH 35/50] refactor: make SQLite search order more reliable - use a stable index checkpoint with creation + name so rows are not skipped - fetch more BM25 matches before reranking, then return the top MAX_SEARCH_RESULTS - add stable tie-breakers in reranking (BM25 and original rank) - improve title matching by checking full words instead of substrings - remove abs normalization in base score --- frappe/search/sqlite_search.py | 75 +++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/frappe/search/sqlite_search.py b/frappe/search/sqlite_search.py index 061145462c..b79d93908a 100644 --- a/frappe/search/sqlite_search.py +++ b/frappe/search/sqlite_search.py @@ -53,6 +53,7 @@ class SQLiteSearchIndexMissingError(Exception): # Search Configuration Constants MAX_SEARCH_RESULTS = 100 +MAX_RERANK_CANDIDATES = 500 SNIPPET_LENGTH = 64 MIN_WORD_LENGTH = 4 MAX_EDIT_DISTANCE = 3 @@ -375,12 +376,17 @@ class SQLiteSearch(ABC): # Process this doctype in batches last_indexed_modified = doctype_progress.get("last_indexed_modified") + last_indexed_name = doctype_progress.get("last_indexed_name") + progress_field = "creation" batch_count = 0 while True: # Get batch of documents docs = self.get_documents_paginated( - doctype, limit=batch_size, last_indexed_modified=last_indexed_modified + doctype, + limit=batch_size, + last_indexed_modified=last_indexed_modified, + last_indexed_name=last_indexed_name, ) if not docs: @@ -398,13 +404,12 @@ class SQLiteSearch(ABC): if documents: self._index_documents(documents) - # Update progress with last processed document's modification time - # Use hardcoded 'modified' field since it's reliable in all Frappe doctypes - last_doc_modified = docs[-1]["modified"] - + # Update progress with last processed document cursor + last_doc_modified = docs[-1].get(progress_field) or docs[-1].get("modified") last_doc_name = docs[-1]["name"] self._update_index_progress(doctype, last_doc_name, last_doc_modified, len(documents)) last_indexed_modified = last_doc_modified + last_indexed_name = last_doc_name batch_count += 1 @@ -614,34 +619,48 @@ class SQLiteSearch(ABC): return records - def get_documents_paginated(self, doctype, limit=1000, last_indexed_modified=None): + def get_documents_paginated( + self, doctype, limit=1000, last_indexed_modified=None, last_indexed_name=None + ): """Get records for a specific doctype with pagination support.""" config = self.doc_configs.get(doctype) if not config: return [] filters = config.get("filters", {}).copy() + sort_field = "creation" - # Ensure 'modified' field is always included for progress tracking + # Ensure cursor fields are included for progress tracking fields = config["fields"].copy() + if sort_field not in fields: + fields.append(sort_field) if "modified" not in fields: fields.append("modified") + if "name" not in fields: + fields.append("name") # Build query with proper ordering and pagination - # Order by modified field for reliable resume capability + # Order by cursor field with name as tie-breaker for stable pagination query = frappe.qb.get_query( doctype, fields=fields, filters=filters, - order_by="creation ASC, name ASC", # Secondary sort by name for consistency + order_by=f"{sort_field} ASC, name ASC", limit=limit, ) - # If resuming from a specific timestamp, filter by modification time - # This is more reliable than name-based filtering for VARCHAR names + # If resuming from a checkpoint, continue from cursor position. + # Include name tie-breaker to avoid skipping docs with same timestamp. if last_indexed_modified: Table = frappe.qb.DocType(doctype) - query = query.where(Table.modified > last_indexed_modified) + sort_column = getattr(Table, sort_field) + if last_indexed_name: + query = query.where( + (sort_column > last_indexed_modified) + | ((sort_column == last_indexed_modified) & (Table.name > last_indexed_name)) + ) + else: + query = query.where(sort_column > last_indexed_modified) docs = query.run(as_dict=True) @@ -854,6 +873,8 @@ class SQLiteSearch(ABC): select_clause = ",\n ".join(select_fields) + candidate_limit = max(MAX_SEARCH_RESULTS, MAX_RERANK_CANDIDATES) + if title_only: sql = f""" SELECT @@ -866,12 +887,12 @@ class SQLiteSearch(ABC): ORDER BY bm25_score LIMIT ? """ - return self.sql(sql, (fts_query, fts_query, *filter_params, MAX_SEARCH_RESULTS), read_only=True) + return self.sql(sql, (fts_query, fts_query, *filter_params, candidate_limit), read_only=True) else: params = [] if "content" in text_fields: params.append(SNIPPET_LENGTH) - params.extend([fts_query, *filter_params, MAX_SEARCH_RESULTS]) + params.extend([fts_query, *filter_params, candidate_limit]) sql = f""" SELECT @@ -883,7 +904,6 @@ class SQLiteSearch(ABC): ORDER BY bm25_score LIMIT ? """ - print(sql) return self.sql(sql, params, read_only=True) def _process_search_results(self, raw_results, query): @@ -923,13 +943,19 @@ class SQLiteSearch(ABC): processed_results.append(result) # Sort by custom score (descending - higher is better) - processed_results.sort(key=lambda x: x["score"], reverse=True) + processed_results.sort( + key=lambda x: ( + -x["score"], + x["bm25_score"] if x["bm25_score"] is not None else float("inf"), + x["original_rank"], + ) + ) # Add modified ranking after custom scoring for i, result in enumerate(processed_results): result["modified_rank"] = i + 1 - return processed_results + return processed_results[:MAX_SEARCH_RESULTS] def get_scoring_pipeline(self): """ @@ -984,13 +1010,22 @@ class SQLiteSearch(ABC): def _get_base_score(self, row, query): """Calculate the base score from BM25.""" - bm25_score = abs(row["bm25_score"]) if row["bm25_score"] is not None else 0 - return 1.0 / (1.0 + bm25_score) if bm25_score > 0 else 0.5 + bm25_score = row["bm25_score"] + if bm25_score is None: + return 0.5 + + # FTS5 BM25 is better when smaller, so don't normalize with abs(). + # Clamp non-positive scores to a strong base to avoid unstable boosts. + if bm25_score <= 0: + return 1.0 + + return 1.0 / (1.0 + bm25_score) def _get_title_boost(self, row, query, query_words): """Calculate the title matching boost based on percentage of words matched.""" original_title = (row["original_title"] or "").lower() query_lower = query.lower() + title_tokens = set(re.findall(r"\w+", original_title)) # Check for exact phrase match first (highest boost) if query_lower in original_title: @@ -1002,7 +1037,7 @@ class SQLiteSearch(ABC): matched_words = 0 for word in query_words: - if word.lower() in original_title: + if word.lower() in title_tokens: matched_words += 1 if matched_words == 0: From 08793c57f7e9721ada97e32f09dc3b3cc4c19e7a Mon Sep 17 00:00:00 2001 From: Aarol D'Souza <98270103+AarDG10@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:58:16 +0530 Subject: [PATCH 36/50] fix: force type check in whitelisted methods 2 (#37086) * fix(diff): add type hints to whitelisted methods * fix(global_search): add type hints to whitelisted methods * fix(custom_html_block): add type hints to whitelisted methods * fix(deleted_document): add type hints to whitelisted methods * fix(log_settings): add type hints to whitelisted methods * fix(role): add type hints to whitelisted methods * fix(user_type): add type hints to whitelisted methods * fix(rq_job): add type hints to whitelisted methods * fix(link_preview): add type hints to whitelisted methods * fix(email_account): add type hints to whitelisted methods * fix(web_form): add type hints to whitelisted methods * fix(web_page_view): add type hints to whitelisted methods * fix(csvutils): add type hints to whitelisted methods * fix(file_manager): add type hints to whitelisted methods * fix(email_body): add type hints to whitelisted methods * fix(email_queue): add type hints to whitelisted methods * fix(email_template): add type hints to whitelisted methods * fix(notification): add type hints to whitelisted methods * fix(email_group): add type hints to whitelisted methods * fix(inbox): add type hints to whitelisted methods * fix(recorder): add type hints to whitelisted methods * fix(sms_settings): add type hints to whitelisted methods * fix: tighten type hints * fix(data_import): add type hints to whitelisted methods * fix(user_permission): add type hints to whitelisted methods * fix(gantt): add type hints to whitelisted methods * fix(like): add type hints to whitelisted methods * fix(search): add type hints to whitelisted methods * fix(onboarding_step): add type hints to whitelisted methods * fix(system_console): add type hints to whitelisted methods * fix(workspace_sidebar): add type hints to whitelisted methods * fix(todo): add type hints to whitelisted methods * fix: correct type hints * fix(print_format): add type hints to whitelisted methods * fix(client): add type hints to whitelisted methods --- .../core/doctype/data_import/data_import.py | 11 ++++++++-- .../deleted_document/deleted_document.py | 4 ++-- .../core/doctype/log_settings/log_settings.py | 2 +- frappe/core/doctype/recorder/recorder.py | 2 +- frappe/core/doctype/role/role.py | 4 +++- frappe/core/doctype/rq_job/rq_job.py | 2 +- .../core/doctype/sms_settings/sms_settings.py | 4 ++-- .../user_permission/user_permission.py | 13 +++++++----- frappe/core/doctype/user_type/user_type.py | 6 ++++-- .../custom_html_block/custom_html_block.py | 4 +++- .../onboarding_step/onboarding_step.py | 2 +- .../doctype/system_console/system_console.py | 2 +- frappe/desk/doctype/todo/todo.py | 2 +- .../workspace_sidebar/workspace_sidebar.py | 2 +- frappe/desk/gantt.py | 2 +- frappe/desk/like.py | 2 +- frappe/desk/link_preview.py | 2 +- frappe/desk/search.py | 6 +++--- .../doctype/email_account/email_account.py | 11 ++++++++-- .../email/doctype/email_group/email_group.py | 4 ++-- .../email/doctype/email_queue/email_queue.py | 4 ++-- .../doctype/email_template/email_template.py | 3 ++- .../doctype/notification/notification.py | 8 +++---- frappe/email/email_body.py | 8 ++++++- frappe/email/inbox.py | 2 +- frappe/utils/csvutils.py | 3 ++- frappe/utils/diff.py | 5 ++++- frappe/utils/file_manager.py | 2 +- frappe/utils/global_search.py | 2 +- frappe/utils/print_format.py | 21 ++++++++++++------- frappe/utils/telemetry/pulse/client.py | 17 ++++++++++++--- frappe/website/doctype/web_form/web_form.py | 7 ++++--- .../doctype/web_page_view/web_page_view.py | 20 +++++++++--------- 33 files changed, 121 insertions(+), 68 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index c6bfe0b66d..6861b5f6b1 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import os +from typing import Any from rq.command import send_stop_job_command from rq.exceptions import InvalidJobOperation @@ -102,7 +103,7 @@ class DataImport(Document): self.payload_count = len(payloads) @frappe.whitelist() - def get_preview_from_template(self, import_file=None, google_sheets_url=None): + def get_preview_from_template(self, import_file: str | None = None, google_sheets_url: str | None = None): if import_file: self.import_file = import_file self.set_delimiters_flag() @@ -203,7 +204,13 @@ def start_import(data_import): @frappe.whitelist() -def download_template(doctype, export_fields=None, export_records=None, export_filters=None, file_type="CSV"): +def download_template( + doctype: str, + export_fields: str | dict[str, list[str]] | None = None, + export_records: str | None = None, + export_filters: str | dict[str, Any] | list[list[Any]] | None = None, + file_type: str = "CSV", +): """ Download template from Exporter :param doctype: Document Type diff --git a/frappe/core/doctype/deleted_document/deleted_document.py b/frappe/core/doctype/deleted_document/deleted_document.py index ef4578f9c9..59a6102336 100644 --- a/frappe/core/doctype/deleted_document/deleted_document.py +++ b/frappe/core/doctype/deleted_document/deleted_document.py @@ -38,7 +38,7 @@ class DeletedDocument(Document): @frappe.whitelist() -def restore(name, alert=True): +def restore(name: str | int, alert: bool = True): deleted = frappe.get_doc("Deleted Document", name) if deleted.restored: @@ -69,7 +69,7 @@ def restore(name, alert=True): @frappe.whitelist() -def bulk_restore(docnames): +def bulk_restore(docnames: str | list[str]): docnames = frappe.parse_json(docnames) message = _("Restoring Deleted Document") restored, invalid, failed = [], [], [] diff --git a/frappe/core/doctype/log_settings/log_settings.py b/frappe/core/doctype/log_settings/log_settings.py index 8501be7b64..b1a6bb36be 100644 --- a/frappe/core/doctype/log_settings/log_settings.py +++ b/frappe/core/doctype/log_settings/log_settings.py @@ -130,7 +130,7 @@ def has_unseen_error_log(): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs -def get_log_doctypes(doctype, txt, searchfield, start, page_len, filters): +def get_log_doctypes(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: list): filters = filters or [] filters.extend( diff --git a/frappe/core/doctype/recorder/recorder.py b/frappe/core/doctype/recorder/recorder.py index 6102d8c736..a212910fdc 100644 --- a/frappe/core/doctype/recorder/recorder.py +++ b/frappe/core/doctype/recorder/recorder.py @@ -116,7 +116,7 @@ def serialize_request(request): @frappe.whitelist() -def add_indexes(indexes): +def add_indexes(indexes: str): frappe.only_for("Administrator") indexes = json.loads(indexes) diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index 3bf470493c..5a161f1b97 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -120,7 +120,9 @@ def get_users(role): # searches for active employees @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs -def role_query(doctype, txt, searchfield, start, page_len, filters): +def role_query( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: list | dict | str +): return frappe.get_all( "Role", limit_start=start, diff --git a/frappe/core/doctype/rq_job/rq_job.py b/frappe/core/doctype/rq_job/rq_job.py index 02375da8bc..98ff58b84b 100644 --- a/frappe/core/doctype/rq_job/rq_job.py +++ b/frappe/core/doctype/rq_job/rq_job.py @@ -241,7 +241,7 @@ def get_all_queued_jobs(): @frappe.whitelist() -def stop_job(job_id): +def stop_job(job_id: str): frappe.get_doc("RQ Job", job_id).stop_job() diff --git a/frappe/core/doctype/sms_settings/sms_settings.py b/frappe/core/doctype/sms_settings/sms_settings.py index 6d9207db88..f33ea63397 100644 --- a/frappe/core/doctype/sms_settings/sms_settings.py +++ b/frappe/core/doctype/sms_settings/sms_settings.py @@ -46,7 +46,7 @@ def validate_receiver_nos(receiver_list): @frappe.whitelist() -def get_contact_number(contact_name, ref_doctype, ref_name): +def get_contact_number(contact_name: str, ref_doctype: str, ref_name: str): "Return mobile number of the given contact." number = frappe.db.sql( """select mobile_no, phone from tabContact @@ -62,7 +62,7 @@ def get_contact_number(contact_name, ref_doctype, ref_name): @frappe.whitelist() -def send_sms(receiver_list, msg, sender_name="", success_msg=True): +def send_sms(receiver_list: str | list[str], msg: str, sender_name: str = "", success_msg: bool = True): send_sms_hook_methods = frappe.get_hooks("send_sms") if send_sms_hook_methods: return frappe.get_attr(send_sms_hook_methods[-1])(receiver_list, msg, sender_name, success_msg) diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index 9001b2893d..380d432833 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import json +from typing import Any import frappe from frappe import _ @@ -85,7 +86,7 @@ def send_user_permissions(bootinfo): @frappe.whitelist() -def get_user_permissions(user=None): +def get_user_permissions(user: str | None = None): """Get all users permissions for the user as a dict of doctype""" # if this is called from client-side, # user can access only his/her user permissions @@ -160,7 +161,9 @@ def user_permission_exists(user, allow, for_value, applicable_for=None): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs -def get_applicable_for_doctype_list(doctype, txt, searchfield, start, page_len, filters): +def get_applicable_for_doctype_list( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict[str, Any] +): actual_doctype = filters.get("doctype") linked_doctypes_map = get_linked_doctypes(actual_doctype, True) @@ -192,7 +195,7 @@ def get_permitted_documents(doctype): @frappe.whitelist() -def check_applicable_doc_perm(user, doctype, docname): +def check_applicable_doc_perm(user: str, doctype: str, docname: str | int): frappe.only_for("System Manager") applicable = [] doc_exists = frappe.get_all( @@ -224,7 +227,7 @@ def check_applicable_doc_perm(user, doctype, docname): @frappe.whitelist() -def clear_user_permissions(user, for_doctype): +def clear_user_permissions(user: str, for_doctype: str): frappe.only_for("System Manager") total = frappe.db.count("User Permission", {"user": user, "allow": for_doctype}) @@ -242,7 +245,7 @@ def clear_user_permissions(user, for_doctype): @frappe.whitelist() -def add_user_permissions(data): +def add_user_permissions(data: str | dict[str, Any]): """Add and update the user permissions""" frappe.only_for("System Manager") if isinstance(data, str): diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 046e3203f9..1b6cd86041 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -218,7 +218,9 @@ def get_non_standard_user_types(): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs -def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters): +def get_user_linked_doctypes( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | list | str +): modules = [d.get("module_name") for d in get_modules_from_app("frappe")] filters = [ @@ -254,7 +256,7 @@ def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters @frappe.whitelist() -def get_user_id(parent): +def get_user_id(parent: str): data = ( frappe.get_all( "DocField", diff --git a/frappe/desk/doctype/custom_html_block/custom_html_block.py b/frappe/desk/doctype/custom_html_block/custom_html_block.py index 35f9c3cc63..837fcb3079 100644 --- a/frappe/desk/doctype/custom_html_block/custom_html_block.py +++ b/frappe/desk/doctype/custom_html_block/custom_html_block.py @@ -27,7 +27,9 @@ class CustomHTMLBlock(Document): @frappe.whitelist() -def get_custom_blocks_for_user(doctype, txt, searchfield, start, page_len, filters): +def get_custom_blocks_for_user( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | str | list +): # return logged in users private blocks and all public blocks customHTMLBlock = DocType("Custom HTML Block") diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py index bd8690bca0..e12e847244 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.py +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py @@ -50,7 +50,7 @@ class OnboardingStep(Document): @frappe.whitelist() -def get_onboarding_steps(ob_steps): +def get_onboarding_steps(ob_steps: str): steps = [] for s in json.loads(ob_steps): doc = frappe.get_doc("Onboarding Step", s.get("step")) diff --git a/frappe/desk/doctype/system_console/system_console.py b/frappe/desk/doctype/system_console/system_console.py index 4f29b8e7fc..d582988a3b 100644 --- a/frappe/desk/doctype/system_console/system_console.py +++ b/frappe/desk/doctype/system_console/system_console.py @@ -51,7 +51,7 @@ class SystemConsole(Document): @frappe.whitelist(methods=["POST"]) -def execute_code(doc): +def execute_code(doc: str): console = frappe.get_doc(json.loads(doc)) console.run() return console.as_dict() diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 77cdfb90db..ddcbc3eb7e 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -173,5 +173,5 @@ def has_permission(doc, ptype="read", user=None): @frappe.whitelist() -def new_todo(description): +def new_todo(description: str): frappe.get_doc({"doctype": "ToDo", "description": description}).insert() diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py index d55139b3e5..b0fe4b5399 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py @@ -195,7 +195,7 @@ def create_workspace_sidebar_for_workspaces(): @frappe.whitelist() -def add_sidebar_items(sidebar_title, sidebar_items): +def add_sidebar_items(sidebar_title: str, sidebar_items: str): sidebar_items = loads(sidebar_items) title = f"{sidebar_title}-{frappe.session.user}" w = frappe.get_doc("Workspace Sidebar", sidebar_title) diff --git a/frappe/desk/gantt.py b/frappe/desk/gantt.py index a09c52dafe..56a207f166 100644 --- a/frappe/desk/gantt.py +++ b/frappe/desk/gantt.py @@ -7,7 +7,7 @@ import frappe @frappe.whitelist() -def update_task(args, field_map): +def update_task(args: str, field_map: str): """Updates Doc (called via gantt) based on passed `field_map`""" args = frappe._dict(json.loads(args)) field_map = frappe._dict(json.loads(field_map)) diff --git a/frappe/desk/like.py b/frappe/desk/like.py index 6399673691..ecdc7d2d66 100644 --- a/frappe/desk/like.py +++ b/frappe/desk/like.py @@ -13,7 +13,7 @@ from frappe.utils import get_link_to_form @frappe.whitelist() -def toggle_like(doctype, name, add=False): +def toggle_like(doctype: str, name: str, add: str | bool = False): """Adds / removes the current user in the `__liked_by` property of the given document. If column does not exist, will add it in the database. diff --git a/frappe/desk/link_preview.py b/frappe/desk/link_preview.py index c9143ef5f1..3873daae56 100644 --- a/frappe/desk/link_preview.py +++ b/frappe/desk/link_preview.py @@ -6,7 +6,7 @@ from frappe.www.printview import set_title_values_for_link_and_dynamic_link_fiel @frappe.whitelist() @http_cache(max_age=60 * 10) -def get_preview_data(doctype, docname): +def get_preview_data(doctype: str, docname: str | int): preview_fields = [] meta = frappe.get_meta(doctype) if not meta.show_preview_popup: diff --git a/frappe/desk/search.py b/frappe/desk/search.py index afb8adcd5f..fb6b9b249f 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -72,7 +72,7 @@ def search_widget( start: int = 0, page_length: int = 10, filters: str | None | dict | list = None, - filter_fields=None, + filter_fields: str | None = None, as_dict: bool = False, reference_doctype: str | None = None, ignore_user_permissions: bool = False, @@ -372,7 +372,7 @@ def relevance_sorter(key, query, as_dict): @frappe.whitelist() -def get_names_for_mentions(search_term): +def get_names_for_mentions(search_term: str): users_for_mentions = frappe.cache.get_value("users_for_mentions", get_users_for_mentions) user_groups = frappe.cache.get_value("user_groups", get_user_groups) @@ -408,7 +408,7 @@ def get_user_groups(): @frappe.whitelist() -def get_link_title(doctype, docname): +def get_link_title(doctype: str, docname: str | int): meta = frappe.get_meta(doctype) if meta.show_title_field_in_link: diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 49a60918bb..73ca59b640 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -821,7 +821,14 @@ class EmailAccount(Document): @frappe.whitelist() -def get_append_to(doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None): +def get_append_to( + doctype: str | None = None, + txt: str | None = None, + searchfield: str | None = None, + start: int | None = None, + page_len: int | None = None, + filters: list | dict | str | None = None, +): txt = txt if txt else "" filters = {"istable": 0, "issingle": 0, "email_append_to": 1} @@ -1054,7 +1061,7 @@ def remove_user_email_inbox(email_account): @frappe.whitelist() -def set_email_password(email_account, password): +def set_email_password(email_account: str, password: str): account = frappe.get_doc("Email Account", email_account) if account.awaiting_password and account.auth_method != "OAuth": account.awaiting_password = 0 diff --git a/frappe/email/doctype/email_group/email_group.py b/frappe/email/doctype/email_group/email_group.py index 0985cba6e1..8101f67851 100755 --- a/frappe/email/doctype/email_group/email_group.py +++ b/frappe/email/doctype/email_group/email_group.py @@ -106,14 +106,14 @@ class EmailGroup(Document): @frappe.whitelist() -def import_from(name, doctype): +def import_from(name: str | int, doctype: str): nlist = frappe.get_doc("Email Group", name) if nlist.has_permission("write"): return nlist.import_from(doctype) @frappe.whitelist() -def add_subscribers(name, email_list): +def add_subscribers(name: str | int, email_list: str | list[str] | tuple[str, ...]): if not isinstance(email_list, list | tuple): email_list = email_list.replace(",", "\n").split("\n") diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index f5cbc66922..41983bd5ed 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -460,7 +460,7 @@ def retry_sending(queues: str | list[str]): @frappe.whitelist() -def send_now(name, force_send: bool = False): +def send_now(name: str | int, force_send: bool = False): record = EmailQueue.find(name) if record: record.check_permission() @@ -468,7 +468,7 @@ def send_now(name, force_send: bool = False): @frappe.whitelist() -def toggle_sending(enable): +def toggle_sending(enable: bool | int | str): frappe.only_for("System Manager") frappe.db.set_default("suspend_email_queue", 0 if sbool(enable) else 1) diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index bf2647c13c..1ab580f585 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import json +from typing import Any import frappe from frappe.model.document import Document @@ -66,7 +67,7 @@ class EmailTemplate(Document): @frappe.whitelist() -def get_email_template(template_name, doc, sender=None): +def get_email_template(template_name: str, doc: str | dict[str, Any], sender: str | None = None): """Return the processed HTML of a email template with the given doc""" email_template = frappe.get_doc("Email Template", template_name) diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 584df7b5f8..c45ba3a3d7 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -92,7 +92,7 @@ class Notification(Document): # START: PreviewRenderer API @frappe.whitelist() - def preview_meets_condition(self, preview_document): + def preview_meets_condition(self, preview_document: str): if not self.condition and not self.filters: return _("Yes") try: @@ -107,7 +107,7 @@ class Notification(Document): return _("Failed to evaluate conditions: {}").format(e) @frappe.whitelist() - def preview_message(self, preview_document): + def preview_message(self, preview_document: str): try: doc = frappe.get_cached_doc(self.document_type, preview_document) context = get_context(doc) @@ -124,7 +124,7 @@ class Notification(Document): return _("Failed to render message: {}").format(e) @frappe.whitelist() - def preview_subject(self, preview_document): + def preview_subject(self, preview_document: str): try: doc = frappe.get_cached_doc(self.document_type, preview_document) context = get_context(doc) @@ -730,7 +730,7 @@ def clear_notification_cache(): @frappe.whitelist() -def get_documents_for_today(notification): +def get_documents_for_today(notification: str): notification = frappe.get_doc("Notification", notification) notification.check_permission("read") return [d.name for d in notification.get_documents_for_today()] diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 5dc3054843..c6be796b49 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -437,7 +437,13 @@ def get_formatted_html( @frappe.whitelist() -def get_email_html(template, args, subject, header=None, with_container=False): +def get_email_html( + template: str, + args: str, + subject: str, + header: str | list | None = None, + with_container: str | int | bool = False, +): import json with_container = cint(with_container) diff --git a/frappe/email/inbox.py b/frappe/email/inbox.py index 2a299ef44a..3a00c34b72 100644 --- a/frappe/email/inbox.py +++ b/frappe/email/inbox.py @@ -38,7 +38,7 @@ def get_email_accounts(user=None): @frappe.whitelist() -def create_email_flag_queue(names, action): +def create_email_flag_queue(names: str, action: str): """create email flag queue to mark email either as read or unread""" def mark_as_seen_unseen(name, action): diff --git a/frappe/utils/csvutils.py b/frappe/utils/csvutils.py index 5bcccafa0f..b1ddde1ee5 100644 --- a/frappe/utils/csvutils.py +++ b/frappe/utils/csvutils.py @@ -4,6 +4,7 @@ import csv import json from csv import Sniffer from io import StringIO +from typing import Any import requests @@ -104,7 +105,7 @@ def read_csv_content(fcontent, use_sniffer: bool = False): @frappe.whitelist() -def send_csv_to_client(args): +def send_csv_to_client(args: str | dict[str, Any]): if isinstance(args, str): args = json.loads(args) diff --git a/frappe/utils/diff.py b/frappe/utils/diff.py index 793de389e8..d8de8eea40 100644 --- a/frappe/utils/diff.py +++ b/frappe/utils/diff.py @@ -1,5 +1,6 @@ import json from difflib import unified_diff +from typing import Any import frappe from frappe.utils import pretty_date @@ -44,7 +45,9 @@ def _get_value_from_version(version_name: int | str, fieldname: str): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs -def version_query(doctype, txt, searchfield, start, page_len, filters): +def version_query( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict[str, Any] +): version_filters = { "docname": filters["docname"], "ref_doctype": filters["ref_doctype"], diff --git a/frappe/utils/file_manager.py b/frappe/utils/file_manager.py index 7867b612a1..1a6029f573 100644 --- a/frappe/utils/file_manager.py +++ b/frappe/utils/file_manager.py @@ -396,7 +396,7 @@ def get_file_name(fname, optional_suffix): @frappe.whitelist() -def add_attachments(doctype, name, attachments): +def add_attachments(doctype: str, name: str | int, attachments: str | list[str]): """Add attachments to the given DocType""" if isinstance(attachments, str): attachments = json.loads(attachments) diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 03a86e47af..8437f736e7 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -477,7 +477,7 @@ def delete_for_document(doc): @frappe.whitelist() -def search(text, start=0, limit=20, doctype=""): +def search(text: str, start: int = 0, limit: int = 20, doctype: str = ""): """ Search for given text in __global_search :param text: phrase to be searched diff --git a/frappe/utils/print_format.py b/frappe/utils/print_format.py index 4e5b5511ce..39b0dc4e47 100644 --- a/frappe/utils/print_format.py +++ b/frappe/utils/print_format.py @@ -11,6 +11,7 @@ from pypdf import PdfWriter import frappe from frappe import _ from frappe.core.doctype.access_log.access_log import make_access_log +from frappe.model.document import Document from frappe.translate import print_language from frappe.utils.jinja import render_template from frappe.utils.pdf import get_pdf @@ -224,11 +225,11 @@ from frappe.deprecation_dumpster import read_multi_pdf def download_pdf( doctype: str, name: str, - format=None, - doc=None, - no_letterhead=0, - language=None, - letterhead=None, + format: str | None = None, + doc: Document | None = None, + no_letterhead: bool | int = 0, + language: str | None = None, + letterhead: str | None = None, pdf_generator: Literal["wkhtmltopdf", "chrome"] | None = None, ): if pdf_generator is None: @@ -255,7 +256,7 @@ def download_pdf( @frappe.whitelist() -def report_to_pdf(html, orientation="Landscape"): +def report_to_pdf(html: str, orientation: str = "Landscape"): make_access_log(file_type="PDF", method="PDF", page=html) frappe.local.response.filename = "report.pdf" frappe.local.response.filecontent = get_pdf( @@ -313,7 +314,13 @@ def render_letterhead_for_print(letterhead: str | None = None, doc: dict | str | @frappe.whitelist() def print_by_server( - doctype, name, printer_setting, print_format=None, doc=None, no_letterhead=0, file_path=None + doctype: str, + name: str | int, + printer_setting: str, + print_format: str | None = None, + doc: Document | None = None, + no_letterhead: bool | int = 0, + file_path: str | None = None, ): print_settings = frappe.get_doc("Network Printer Settings", printer_setting) try: diff --git a/frappe/utils/telemetry/pulse/client.py b/frappe/utils/telemetry/pulse/client.py index 9703f8f70b..6dc8430c41 100644 --- a/frappe/utils/telemetry/pulse/client.py +++ b/frappe/utils/telemetry/pulse/client.py @@ -1,5 +1,6 @@ import time from contextlib import suppress +from typing import Any from orjson import JSONDecodeError @@ -23,7 +24,15 @@ def is_enabled() -> bool: @frappe.whitelist() -def capture(event_name, site=None, app=None, user=None, captured_at=None, properties=None, interval=None): +def capture( + event_name: str, + site: str | None = None, + app: str | None = None, + user: str | None = None, + captured_at: str | None = None, + properties: dict[str, Any] | None = None, + interval: int | str | None = None, +): if not is_enabled(): return @@ -45,7 +54,7 @@ def capture(event_name, site=None, app=None, user=None, captured_at=None, proper @frappe.whitelist() -def bulk_capture(events): +def bulk_capture(events: str | list[dict[str, Any]]): if not is_enabled(): return @@ -226,7 +235,9 @@ class EventQueue: @frappe.whitelist() -def get_debug_info(fetch_events=None, fetch_rate_limited_events=None): +def get_debug_info( + fetch_events: int | str | bool | None = None, fetch_rate_limited_events: int | str | bool | None = None +): frappe.only_for("System Manager") info = frappe._dict() diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index bd12631577..ca89010def 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -3,6 +3,7 @@ import json import os +from typing import Any import frappe from frappe import _, scrub @@ -615,7 +616,7 @@ def get_web_form_module(doc): @frappe.whitelist(allow_guest=True) @rate_limit(key="web_form", limit=10, seconds=60) -def accept(web_form, data): +def accept(web_form: str, data: str): """Save the web form""" data = frappe._dict(json.loads(data)) @@ -732,7 +733,7 @@ def delete(web_form_name: str, docname: str | int): @frappe.whitelist() -def delete_multiple(web_form_name: str, docnames): +def delete_multiple(web_form_name: str, docnames: str): web_form = frappe.get_lazy_doc("Web Form", web_form_name) docnames = json.loads(docnames) @@ -807,7 +808,7 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str | @frappe.whitelist() -def get_in_list_view_fields(doctype): +def get_in_list_view_fields(doctype: str): meta = frappe.get_meta(doctype) fields = [] diff --git a/frappe/website/doctype/web_page_view/web_page_view.py b/frappe/website/doctype/web_page_view/web_page_view.py index bba9e76a30..56c6ab5bf0 100644 --- a/frappe/website/doctype/web_page_view/web_page_view.py +++ b/frappe/website/doctype/web_page_view/web_page_view.py @@ -43,15 +43,15 @@ class WebPageView(Document): @frappe.whitelist(allow_guest=True) def make_view_log( - referrer=None, - browser=None, - version=None, - user_tz=None, - source=None, - campaign=None, - medium=None, - content=None, - visitor_id=None, + referrer: str | None = None, + browser: str | None = None, + version: str | None = None, + user_tz: str | None = None, + source: str | None = None, + campaign: str | None = None, + medium: str | None = None, + content: str | None = None, + visitor_id: str | None = None, ): if not is_tracking_enabled(): return @@ -100,7 +100,7 @@ def make_view_log( @frappe.whitelist() @redis_cache(ttl=5 * 60) -def get_page_view_count(path): +def get_page_view_count(path: str): return frappe.db.count("Web Page View", filters={"path": path}) From 063e600c308e0bea269366bfe3c410ca10de7234 Mon Sep 17 00:00:00 2001 From: Akash Tom Date: Thu, 19 Feb 2026 16:05:12 +0530 Subject: [PATCH 37/50] fix(grid): saving of values on navigation using arrow keys (#37243) --- frappe/public/js/frappe/form/grid_row.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index f3fa51b8d9..9558083e05 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -1283,11 +1283,14 @@ export default class GridRow { return false; } - base.toggle_editable_row(); - var input = base.columns[fieldname].field.$input; - if (input) { - input.focus(); - } + field.parse_validate_and_set_in_model(field.get_input_value()).then(() => { + base.toggle_editable_row(); + const input = base.columns[fieldname].field.$input; + if (input) { + input.focus(); + } + }); + return true; }; From 13bd30edd503e3b736535328c764261d241cd838 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:41:44 +0100 Subject: [PATCH 38/50] fix: limit config should not be mandatory (#37247) --- frappe/core/doctype/user_type/user_type.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 1b6cd86041..be839e7fcb 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -84,13 +84,14 @@ class UserType(Document): title=_("Permission Error"), ) - if not limit: - frappe.throw( + if limit is None: + frappe.msgprint( _("The limit has not set for the user type {0} in the site config file.").format( frappe.bold(self.name) ), title=_("Set Limit"), ) + return if self.user_doctypes and len(self.user_doctypes) > limit: frappe.throw( From 4717c64cf0c9c78eeae6a31157379f04f90ce2c4 Mon Sep 17 00:00:00 2001 From: sokumon Date: Thu, 19 Feb 2026 16:16:27 +0530 Subject: [PATCH 39/50] fix: always show upgrade button FC user --- frappe/public/js/billing.bundle.js | 43 +++++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/frappe/public/js/billing.bundle.js b/frappe/public/js/billing.bundle.js index 3c90eee279..b2f384c715 100644 --- a/frappe/public/js/billing.bundle.js +++ b/frappe/public/js/billing.bundle.js @@ -5,7 +5,7 @@ $(document).ready(function () { const response = frappe.boot.site_info; const trial_end_date = new Date(response.trial_end_date); frappeCloudBaseEndpoint = response.base_url; - isFCUser = response.is_fc_user; + // isFCUser = response.is_fc_user; const today = new Date(); const diffTime = trial_end_date - today; @@ -24,15 +24,19 @@ $(document).ready(function () { popper: true, primary_button_alignment: "right", }; - isFCUser = true; if (isFCUser) { - card_args.primary_action_label = "Upgrade"; - card_args.primary_action_suffix_icon = "square-arrow-out-up-right"; - card_args.styles = { - "sidebar-card-button-bg-color": "var(--surface-gray-2)", - "sidebar-card-button-color": "var(--ink-gray-7)", - "sidebar-card-button-outline": "var(--ink-gray-7)", - }; + $.extend(card_args, { + primary_action_label: "Upgrade", + primary_action_suffix_icon: "square-arrow-out-up-right", + styles: { + "sidebar-card-button-bg-color": "var(--surface-gray-2)", + "sidebar-card-button-color": "var(--ink-gray-7)", + "sidebar-card-button-outline": "var(--ink-gray-7)", + }, + primary_action: () => { + openFrappeCloudDashboard(); + }, + }); } $(document).on("desktop_screen", function (event, data) { if ( @@ -54,16 +58,23 @@ $(document).ready(function () { }); $(document).on("sidebar_setup", function (event, data) { let sidebar = data.sidebar; - // card_args.close_button = null; - sidebar.add_card({ + let sidebar_card_args = { title: card_args.title, icon: "info", message: card_args.message, - // primary_action_icon: "zap", - // primary_action_label: "Upgrade", - // primary_button_width: "full", - // primary_action: () => {}, - }); + }; + isFCUser = true; + if (isFCUser) { + $.extend(sidebar_card_args, { + primary_action_label: "Upgrade", + primary_action_icon: "zap", + primary_button_width: "full", + primary_action: () => { + openFrappeCloudDashboard(); + }, + }); + } + sidebar.add_card(sidebar_card_args); }); }); From 3f053667ac97e6ade3cc1c706fd7ac758ba4ab7e Mon Sep 17 00:00:00 2001 From: sokumon Date: Thu, 19 Feb 2026 17:06:08 +0530 Subject: [PATCH 40/50] feat: make the desktop banner dismissbable for a day --- frappe/public/js/billing.bundle.js | 6 ++-- .../js/frappe/ui/sidebar/sidebar_card.js | 29 +++++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/billing.bundle.js b/frappe/public/js/billing.bundle.js index b2f384c715..fb83f48dba 100644 --- a/frappe/public/js/billing.bundle.js +++ b/frappe/public/js/billing.bundle.js @@ -1,11 +1,11 @@ let frappeCloudBaseEndpoint = "https://frappecloud.com"; -let isFCUser = true; +let isFCUser = false; $(document).ready(function () { const response = frappe.boot.site_info; const trial_end_date = new Date(response.trial_end_date); frappeCloudBaseEndpoint = response.base_url; - // isFCUser = response.is_fc_user; + isFCUser = response.is_fc_user; const today = new Date(); const diffTime = trial_end_date - today; @@ -23,6 +23,8 @@ $(document).ready(function () { close_button: true, popper: true, primary_button_alignment: "right", + dismiss_key: `${frappe.boot.site_info.name}_trial_card_time`, + dismiss_it_for: "day", }; if (isFCUser) { $.extend(card_args, { diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_card.js b/frappe/public/js/frappe/ui/sidebar/sidebar_card.js index c6ce41de75..45799a6fbc 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_card.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_card.js @@ -1,6 +1,5 @@ import { createPopper } from "@popperjs/core"; frappe.provide("frappe.ui"); - // icon, title, message, condition, primary_action_label, primary_action frappe.ui.SidebarCard = class SidebarCard { constructor(opts) { @@ -9,15 +8,26 @@ frappe.ui.SidebarCard = class SidebarCard { right: "flex-end", left: "flex-start", }; + this.dismiss_intervals = { + minute: 60 * 1000, + hour: 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + }; this.make(opts); this.setup(); - this.display = false; this.set_styles(); } make() { if (!this.icon) { this.icon = "info"; } + if (this.dismiss_it_for) { + const next_time_for_show = localStorage.getItem(this.get_dismiss_key()); + if (next_time_for_show && Date.now() < Number(next_time_for_show)) { + this.hide(); + } + } this.card = $( frappe.render_template("sidebar_card", { card: this, @@ -41,6 +51,7 @@ frappe.ui.SidebarCard = class SidebarCard { } this.card.prependTo(this.parent); this.set_button_alignment(); + this.show(); } setup() { this.setup_primary_action(); @@ -56,11 +67,18 @@ frappe.ui.SidebarCard = class SidebarCard { hide() { this.display = false; this.parent.removeAttr("data-show"); + this.card.removeClass("d-inline-flex"); + this.card.addClass("hidden"); } show() { this.display = true; this.parent.attr("data-show", ""); - this.popper.update(); + this.popper && this.popper.update(); + this.card.addClass("d-inline-flex"); + this.card.removeClass("hidden"); + } + get_dismiss_key() { + return this.dismiss_key || "card_next_show_time"; } setup_primary_action() { const me = this; @@ -73,6 +91,11 @@ frappe.ui.SidebarCard = class SidebarCard { const me = this; if (this.close_button) { this.card.find(".close-button").on("click", function () { + if (me.dismiss_it_for) { + let next_show_time = Date.now() + me.dismiss_intervals[me.dismiss_it_for]; + + localStorage.setItem(me.get_dismiss_key(), next_show_time); + } me.toggle(); }); } From ffe362316d88323a9e65a2569711f0982c0e6406 Mon Sep 17 00:00:00 2001 From: Sumit Jain <59503001+sumitjain236@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:06:27 +0530 Subject: [PATCH 41/50] fix: ensure import log is only used for persisted Data Import records (#36999) --- frappe/core/doctype/data_import/importer.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index f74022a5f6..3ddc070fc5 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -93,15 +93,19 @@ class Importer: return # setup import log - import_log = ( - frappe.get_all( - "Data Import Log", - fields=["row_indexes", "success", "log_index"], - filters={"data_import": self.data_import.name}, - order_by="log_index", + # Only use import log for retry/resume when Data Import is persisted in DB. + # For bench data-import (CLI), the doc is never inserted, so we must not reuse logs + import_log = [] + if self.data_import.name and frappe.db.exists("Data Import", self.data_import.name): + import_log = ( + frappe.get_all( + "Data Import Log", + fields=["row_indexes", "success", "log_index"], + filters={"data_import": self.data_import.name}, + order_by="log_index", + ) + or [] ) - or [] - ) log_index = 0 From e1e871428ffa636008b1fa3276e0257f185b74b1 Mon Sep 17 00:00:00 2001 From: sokumon Date: Thu, 19 Feb 2026 17:49:45 +0530 Subject: [PATCH 42/50] fix: remove sidebar billing card --- frappe/public/js/billing.bundle.js | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/frappe/public/js/billing.bundle.js b/frappe/public/js/billing.bundle.js index fb83f48dba..c0fa0bcda0 100644 --- a/frappe/public/js/billing.bundle.js +++ b/frappe/public/js/billing.bundle.js @@ -58,26 +58,6 @@ $(document).ready(function () { }); } }); - $(document).on("sidebar_setup", function (event, data) { - let sidebar = data.sidebar; - let sidebar_card_args = { - title: card_args.title, - icon: "info", - message: card_args.message, - }; - isFCUser = true; - if (isFCUser) { - $.extend(sidebar_card_args, { - primary_action_label: "Upgrade", - primary_action_icon: "zap", - primary_button_width: "full", - primary_action: () => { - openFrappeCloudDashboard(); - }, - }); - } - sidebar.add_card(sidebar_card_args); - }); }); function setErrorMessage(message) { From 9dbf50b58104100e3862fe2f4f46fb902e519e61 Mon Sep 17 00:00:00 2001 From: Shrihari Mahabal Date: Thu, 19 Feb 2026 22:20:50 +0530 Subject: [PATCH 43/50] fix: re-initialize awesomebar after exiting edit layout --- frappe/desk/page/desktop/desktop.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index 36e4633855..f6d48f63c9 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -246,6 +246,7 @@ class DesktopPage { make() { this.page.page_head.hide(); $(this.page.body).empty(); + this.awesomebar_setup = false; $(frappe.render_template("desktop")).appendTo(this.page.body); if (this.data !== undefined) { this.render(); From d3f090edac60d35fc14d481cb994b95041d2760c Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:54:35 +0530 Subject: [PATCH 44/50] perf: avoid layout thrashing in grid `setup_toolbar` (#37265) --- frappe/public/js/frappe/form/grid.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index c4ef420b8d..8086abc224 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -593,8 +593,9 @@ export default class Grid { } setup_toolbar() { - if (this.is_editable()) { - this.wrapper.find(".grid-footer").toggle(true); + const is_editable = this.is_editable(); + if (is_editable) { + this.wrapper.find(".grid-footer").removeClass("hidden"); const num_selected_rows = this.get_selected_children().length; // show, hide buttons to add rows @@ -619,12 +620,12 @@ export default class Grid { this.grid_rows.length < this.grid_pagination.page_length && !this.df.allow_bulk_edit ) { - this.wrapper.find(".grid-footer").toggle(false); + this.wrapper.find(".grid-footer").addClass("hidden"); } this.wrapper .find(".grid-add-row, .grid-add-multiple-rows, .grid-upload") - .toggle(this.is_editable()); + .toggleClass("hidden", !is_editable); } truncate_rows() { From 6aa28b4cf8c269a03e3db63c2a3b58976a1f2a97 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:01:25 +0530 Subject: [PATCH 45/50] perf: scope grid container selector to grid wrapper (#37268) --- frappe/public/js/frappe/form/grid_row.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 9558083e05..a4e4701e4d 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -777,9 +777,7 @@ export default class GridRow { } }); - let current_grid = $( - `div[data-fieldname="${this.grid.df.fieldname}"] .form-grid-container` - ); + let current_grid = this.grid.wrapper.find(".form-grid-container"); if (total_colsize > 10) { current_grid.addClass("column-limit-reached"); } else if (current_grid.hasClass("column-limit-reached")) { From bcbe34a08a9e61dd2062e5500562cd26c3563c0a Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:20:42 +0530 Subject: [PATCH 46/50] fix: identity-based grid row matching to prevent row contamination (#37259) Replace index-based update_doc with doc object identity matching via Map. Prevents GridRow objects created for one doc from being reassigned to a different doc on refresh. --- frappe/public/js/frappe/form/grid.js | 85 ++++++++++++++----- .../public/js/frappe/form/grid_pagination.js | 3 +- frappe/public/js/frappe/form/grid_row.js | 6 -- 3 files changed, 63 insertions(+), 31 deletions(-) diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 8086abc224..e901dc63a5 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -522,12 +522,11 @@ export default class Grid { this.grid_rows = []; } - this.truncate_rows(); /** @type {Record} */ this.grid_rows_by_docname = {}; this.grid_pagination.update_page_numbers(); - this.render_result_rows($rows, false); + this.render_result_rows($rows); this.grid_pagination.check_page_number(); this.wrapper.find(".grid-empty").toggleClass("hidden", Boolean(this.data.length)); @@ -553,14 +552,30 @@ export default class Grid { this.wrapper.trigger("change"); } - render_result_rows($rows, append_row) { + render_result_rows($rows) { + if (!$rows) { + $rows = $(this.parent).find(".rows"); + } + let result_length = this.grid_pagination.get_result_length(); let page_index = this.grid_pagination.page_index; let page_length = this.grid_pagination.page_length; + let page_start = (page_index - 1) * page_length; if (!this.grid_rows) { return; } - for (var ri = (page_index - 1) * page_length; ri < result_length; ri++) { + + // index existing rows by doc object reference for identity-based matching + let rows_by_doc = new Map(); + for (let row of this.grid_rows) { + if (row?.doc) { + rows_by_doc.set(row.doc, row); + } + } + + let matched_rows = new Set(); + + for (var ri = page_start; ri < result_length; ri++) { var d = this.data[ri]; if (!d) { return; @@ -571,10 +586,10 @@ export default class Grid { if (d.name === undefined) { d.name = this.get_random_name(); } - let grid_row; - if (this.grid_rows[ri] && !append_row) { - grid_row = this.grid_rows[ri]; - grid_row.update_doc(d); + + let grid_row = rows_by_doc.get(d); + if (grid_row) { + matched_rows.add(grid_row); grid_row.refresh(); } else { grid_row = new GridRow({ @@ -585,11 +600,44 @@ export default class Grid { frm: this.frm, grid: this, }); - this.grid_rows[ri] = grid_row; } - + this.grid_rows[ri] = grid_row; this.grid_rows_by_docname[d.name] = grid_row; } + + // remove stale / invisible rows + for (let [, row] of rows_by_doc) { + if (!matched_rows.has(row)) { + row.wrapper.remove(); + } + } + + // reorder DOM from the first mismatch onward + let $children = $rows.children(); + let page_count = result_length - page_start; + let reorder_from = -1; + for (let i = 0; i < page_count; i++) { + if ($children.get(i) !== this.grid_rows[page_start + i].wrapper.get(0)) { + reorder_from = i; + break; + } + } + if (reorder_from >= 0) { + for (let ri = page_start + reorder_from; ri < result_length; ri++) { + $rows.append(this.grid_rows[ri].wrapper); + } + } + + // clear non-visible slots to prevent duplicates and stale references + for (let i = 0; i < this.grid_rows.length; i++) { + if (i < page_start || i >= result_length) { + delete this.grid_rows[i]; + } + } + + if (this.grid_rows.length > this.data.length) { + this.grid_rows.length = this.data.length; + } } setup_toolbar() { @@ -628,17 +676,6 @@ export default class Grid { .toggleClass("hidden", !is_editable); } - truncate_rows() { - if (this.grid_rows.length > this.data.length) { - // remove extra rows - for (var i = this.data.length; i < this.grid_rows.length; i++) { - var grid_row = this.grid_rows[i]; - if (grid_row) grid_row.wrapper.remove(); - } - this.grid_rows.splice(this.data.length); - } - } - setup_fields() { // reset docfield if (this.frm && this.frm.docname) { @@ -1334,7 +1371,9 @@ export default class Grid { } for (let row of this.grid_rows) { - let docfield = row?.docfields?.find((d) => d.fieldname === fieldname); + if (!row) continue; + + let docfield = row.docfields?.find((d) => d.fieldname === fieldname); if (docfield) { docfield[property] = value; } else { @@ -1358,7 +1397,7 @@ export default class Grid { get_current_row(target) { let current_row = null; for (let i = 0; i < this.grid_rows.length; i++) { - if (this.grid_rows[i].wrapper.get(0).contains(target)) { + if (this.grid_rows[i]?.wrapper.get(0).contains(target)) { current_row = i; } } diff --git a/frappe/public/js/frappe/form/grid_pagination.js b/frappe/public/js/frappe/form/grid_pagination.js index a4cd6bc387..2dbc9d04a8 100644 --- a/frappe/public/js/frappe/form/grid_pagination.js +++ b/frappe/public/js/frappe/form/grid_pagination.js @@ -150,8 +150,7 @@ export default class GridPagination { } else { this.page_index = index; } - let $rows = $(this.grid.parent).find(".rows").empty(); - this.grid.render_result_rows($rows, true); + this.grid.render_result_rows(); if (this.$page_number) { this.$page_number.val(index); this.$page_number.css("width", (index.toString().length + 1) * 8 + "px"); diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index a4e4701e4d..348bd13840 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -53,12 +53,6 @@ export default class GridRow { this.wrapper.appendTo(this.parent); } - update_doc(doc) { - const changed = !this.doc || this.doc !== doc; - this.doc = doc; - if (changed) this.set_docfields(); - } - set_docfields() { if (this.doc && this.parent_df.options) { this.docfields = frappe.meta.get_docfields( From f6df0674e483cefad82ce4047f7aea5c12e7738c Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:02:40 +0530 Subject: [PATCH 47/50] fix: migrate child row docfield_copy on rename in update_in_locals (#37274) --- frappe/public/js/frappe/model/sync.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/model/sync.js b/frappe/public/js/frappe/model/sync.js index 104406b330..9fc16721d2 100644 --- a/frappe/public/js/frappe/model/sync.js +++ b/frappe/public/js/frappe/model/sync.js @@ -157,14 +157,21 @@ Object.assign(frappe.model, { // if incoming row is not registered, register it if (!locals[updated_child_doc.doctype][updated_child_doc.name]) { + const old_name = local_child_doc_in_parent.name; + // detach old key - delete locals[updated_child_doc.doctype][ - local_child_doc_in_parent.name - ]; + delete locals[updated_child_doc.doctype][old_name]; // re-attach with new name locals[updated_child_doc.doctype][updated_child_doc.name] = local_child_doc_in_parent; + + // migrate per-row docfield overrides to new name + const dc = frappe.meta.docfield_copy[updated_child_doc.doctype]; + if (dc?.[old_name]) { + dc[updated_child_doc.name] = dc[old_name]; + delete dc[old_name]; + } } // row exists, just copy the values From c605130fbdf4273d6399b393d1bb31c769468ccf Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:35:16 +0530 Subject: [PATCH 48/50] fix: prevent child row identity corruption on reorder in update_in_locals (#37280) --- frappe/public/js/frappe/model/sync.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/model/sync.js b/frappe/public/js/frappe/model/sync.js index 9fc16721d2..47051c5c5b 100644 --- a/frappe/public/js/frappe/model/sync.js +++ b/frappe/public/js/frappe/model/sync.js @@ -127,6 +127,7 @@ Object.assign(frappe.model, { } // child table, override each row and append new rows if required + const incoming_names = new Set(updated_doc[fieldname].map((d) => d.name)); for (let i = 0; i < updated_doc[fieldname].length; i++) { let updated_child_doc = updated_doc[fieldname][i]; let local_child_doc_in_parent = local_parent_doc[fieldname][i]; @@ -143,8 +144,12 @@ Object.assign(frappe.model, { } continue; } - if (local_child_doc_in_parent) { - // deleted and added again + if ( + local_child_doc_in_parent && + !incoming_names.has(local_child_doc_in_parent.name) + ) { + // row at this position is truly deleted/replaced — safe to + // reuse the object for the incoming row if (!locals[updated_child_doc.doctype]) locals[updated_child_doc.doctype] = {}; @@ -178,7 +183,9 @@ Object.assign(frappe.model, { Object.assign(local_child_doc_in_parent, updated_child_doc); clear_keys(updated_child_doc, local_child_doc_in_parent); } else { - local_parent_doc[fieldname].push(updated_child_doc); + // row at this position is needed at a different index + // (or no row here) — create a fresh local entry + local_parent_doc[fieldname][i] = updated_child_doc; if (!updated_child_doc.parent) updated_child_doc.parent = updated_doc.name; frappe.model.add_to_locals(updated_child_doc); } From b3450cd8681aab0ccdb4c37ec57b1bac7ed9219b Mon Sep 17 00:00:00 2001 From: sokumon Date: Fri, 20 Feb 2026 03:27:48 +0530 Subject: [PATCH 49/50] fix(minor): add tooltip for group sidebar items --- frappe/public/js/frappe/ui/sidebar/sidebar_card.js | 13 +++++++------ frappe/public/js/frappe/ui/sidebar/sidebar_item.js | 1 - 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_card.js b/frappe/public/js/frappe/ui/sidebar/sidebar_card.js index 45799a6fbc..bcd00368b4 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_card.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_card.js @@ -22,17 +22,18 @@ frappe.ui.SidebarCard = class SidebarCard { if (!this.icon) { this.icon = "info"; } - if (this.dismiss_it_for) { - const next_time_for_show = localStorage.getItem(this.get_dismiss_key()); - if (next_time_for_show && Date.now() < Number(next_time_for_show)) { - this.hide(); - } - } this.card = $( frappe.render_template("sidebar_card", { card: this, }) ); + if (this.dismiss_it_for) { + const next_time_for_show = localStorage.getItem(this.get_dismiss_key()); + if (next_time_for_show && Date.now() < Number(next_time_for_show)) { + this.hide(); + return; + } + } if (this.popper) { this.popper = createPopper($(this.trigger).get(0), $(this.parent).get(0), { modifiers: [ diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index 067af8afb3..21f62c0915 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -234,7 +234,6 @@ frappe.ui.sidebar_item.TypeSectionBreak = class SectionBreakSidebarItem extends } else { $(me.wrapper.find(".section-break")).addClass("hidden"); $(me.wrapper.find(".divider")).removeClass("hidden"); - $(me.wrapper).removeAttr("data-original-title"); me.old_state = me.collapsed; me.open(); if (me.item.indent) { From adb537e46b6262925171ca1563615b8076c7c2e8 Mon Sep 17 00:00:00 2001 From: sokumon Date: Fri, 20 Feb 2026 03:31:16 +0530 Subject: [PATCH 50/50] fix: route using title --- 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 7f3cf14365..2690d5dd74 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -73,7 +73,7 @@ function get_route(desktop_icon) { if (workspaces) { let args = { type: "workspace", - name: first_link.link_to, + name: workspaces.title, public: workspaces.public ? 1 : 0, route_options: { sidebar: desktop_icon.label,