From 9a25674c49e6d68fde63bf706eabdbe555bb318b Mon Sep 17 00:00:00 2001 From: "[Kesavan-001]" <[kesavanp0395@gmail.com]> Date: Wed, 11 Feb 2026 12:19:52 +0530 Subject: [PATCH 001/108] fix: Add Item Below not working in Sidebar Editor --- frappe/public/js/frappe/ui/sidebar/sidebar_item.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index 760b81e193..efc3a8686c 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -128,7 +128,8 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Add Item Below", icon: "add", onClick: () => { - frappe.app.sidebar.editor.perform_action("add_below", me.item); + console.log("Moving"); + frappe.app.sidebar.editor.perform_action("add_item_below", me.item); }, }, { From 361ffab36a40622f0d0e993a707d914c51c3180f Mon Sep 17 00:00:00 2001 From: "[Kesavan-001]" <[kesavanp0395@gmail.com]> Date: Wed, 11 Feb 2026 12:24:20 +0530 Subject: [PATCH 002/108] fix: Add Item Below not working in Sidebar Editor --- frappe/public/js/frappe/ui/sidebar/sidebar_item.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index efc3a8686c..23bc095e35 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -128,7 +128,6 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Add Item Below", icon: "add", onClick: () => { - console.log("Moving"); frappe.app.sidebar.editor.perform_action("add_item_below", me.item); }, }, @@ -143,7 +142,6 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Delete", icon: "trash-2", onClick: () => { - console.log(me.item); frappe.app.sidebar.editor.perform_action("delete", me.item); }, }, From 319786cd97c812d0c18ffd75530cc820b42a9835 Mon Sep 17 00:00:00 2001 From: "[Kesavan-001]" <[kesavanp0395@gmail.com]> Date: Wed, 11 Feb 2026 12:45:45 +0530 Subject: [PATCH 003/108] fix: Add Item Below not working in Sidebar Editor --- frappe/public/js/frappe/ui/sidebar/sidebar_editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js index f952727da7..b806c19e10 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js @@ -566,7 +566,7 @@ export class SidebarEditor { this.delete_item(item_data); break; case "add_item_below": - this.edit_item(item_data); + this.add_below(item_data); break; case "duplicate": this.duplicate_item(item_data); From 7958ffdda92ef051be2cb01a13cd59f93b2faf62 Mon Sep 17 00:00:00 2001 From: "[Kesavan-001]" <[kesavanp0395@gmail.com]> Date: Wed, 11 Feb 2026 14:33:42 +0530 Subject: [PATCH 004/108] fix: Add Item Below not working in Sidebar Editor --- frappe/public/js/frappe/ui/sidebar/sidebar_editor.js | 2 +- frappe/public/js/frappe/ui/sidebar/sidebar_item.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js index b806c19e10..8b849382e8 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js @@ -565,7 +565,7 @@ export class SidebarEditor { case "delete": this.delete_item(item_data); break; - case "add_item_below": + case "add_below": this.add_below(item_data); break; case "duplicate": diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index 23bc095e35..760b81e193 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -128,7 +128,7 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Add Item Below", icon: "add", onClick: () => { - frappe.app.sidebar.editor.perform_action("add_item_below", me.item); + frappe.app.sidebar.editor.perform_action("add_below", me.item); }, }, { @@ -142,6 +142,7 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Delete", icon: "trash-2", onClick: () => { + console.log(me.item); frappe.app.sidebar.editor.perform_action("delete", me.item); }, }, From 697978c8f3aa2ad192a518dc4e9d394eaf4dcb27 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Fri, 13 Feb 2026 21:37:57 +0530 Subject: [PATCH 005/108] fix(website_404): cache website 404 results wrt to users fixes #37007 --- frappe/website/page_renderers/not_found_page.py | 2 +- frappe/website/path_resolver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py index 704dca77d1..1898d7dece 100644 --- a/frappe/website/page_renderers/not_found_page.py +++ b/frappe/website/page_renderers/not_found_page.py @@ -21,7 +21,7 @@ class NotFoundPage(TemplatePage): def render(self): if self.can_cache_404(): - frappe.cache.hset("website_404", self.request_url, True) + frappe.cache.hset("website_404", f"{frappe.session.user}|{self.request_url}", True) return super().render() def can_cache_404(self): diff --git a/frappe/website/path_resolver.py b/frappe/website/path_resolver.py index 0a2e302118..929438e055 100644 --- a/frappe/website/path_resolver.py +++ b/frappe/website/path_resolver.py @@ -35,7 +35,7 @@ class PathResolver: return "desk", TemplatePage("desk", self.http_status_code) # check if the request url is in 404 list - if request.url and can_cache() and frappe.cache.hget("website_404", request.url): + if request.url and can_cache() and frappe.cache.hget("website_404", f"{frappe.session.user}|{request.url}"): return self.path, NotFoundPage(self.path) try: From a534936726adaaaa5d6b44dfacd036659b2c67df Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Mon, 16 Feb 2026 22:15:01 +0530 Subject: [PATCH 006/108] Revert "fix(website_404): cache website 404 results wrt to users" This reverts commit 697978c8f3aa2ad192a518dc4e9d394eaf4dcb27. --- frappe/website/page_renderers/not_found_page.py | 2 +- frappe/website/path_resolver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py index 1898d7dece..704dca77d1 100644 --- a/frappe/website/page_renderers/not_found_page.py +++ b/frappe/website/page_renderers/not_found_page.py @@ -21,7 +21,7 @@ class NotFoundPage(TemplatePage): def render(self): if self.can_cache_404(): - frappe.cache.hset("website_404", f"{frappe.session.user}|{self.request_url}", True) + frappe.cache.hset("website_404", self.request_url, True) return super().render() def can_cache_404(self): diff --git a/frappe/website/path_resolver.py b/frappe/website/path_resolver.py index 929438e055..0a2e302118 100644 --- a/frappe/website/path_resolver.py +++ b/frappe/website/path_resolver.py @@ -35,7 +35,7 @@ class PathResolver: return "desk", TemplatePage("desk", self.http_status_code) # check if the request url is in 404 list - if request.url and can_cache() and frappe.cache.hget("website_404", f"{frappe.session.user}|{request.url}"): + if request.url and can_cache() and frappe.cache.hget("website_404", request.url): return self.path, NotFoundPage(self.path) try: From 9dcaab96eef44c54aa3e7d53ae9afc30f21357cb Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Mon, 16 Feb 2026 23:41:32 +0530 Subject: [PATCH 007/108] fix(website_404): skip caching 404 for pages with permission checks --- .../website/page_renderers/not_found_page.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py index 704dca77d1..9b980f5d51 100644 --- a/frappe/website/page_renderers/not_found_page.py +++ b/frappe/website/page_renderers/not_found_page.py @@ -2,6 +2,7 @@ import os from urllib.parse import urlparse import frappe +from frappe.website.page_renderers.document_page import _find_matching_document_webview from frappe.website.page_renderers.template_page import TemplatePage from frappe.website.utils import can_cache @@ -26,10 +27,26 @@ class NotFoundPage(TemplatePage): def can_cache_404(self): # do not cache 404 for custom homepages - return can_cache() and self.request_url and not self.is_custom_home_page() + # also skip caching docs with website permission checks (access is dynamic) + return ( + can_cache() + and self.request_url + and not self.is_custom_home_page() + and not self.has_website_permission_check() + ) def is_custom_home_page(self): url_parts = urlparse(self.request_url) request_url = os.path.splitext(url_parts.path)[0] request_path = os.path.splitext(self.request_path)[0] return request_url in HOMEPAGE_PATHS and request_path not in HOMEPAGE_PATHS + + def has_website_permission_check(self): + request_path = os.path.splitext(self.request_path)[0] + if not (document := _find_matching_document_webview(request_path)): + return False + doctype, docname = document + doc = frappe.get_cached_doc(doctype, docname) + return hasattr(doc, "has_website_permission") or bool( + frappe.get_hooks("has_website_permission", {}).get(doctype) + ) From b781fa4ee34bfe66651bdcd35ef1dcbac6d9fd8c Mon Sep 17 00:00:00 2001 From: Sumit Jain Date: Wed, 18 Feb 2026 14:04:58 +0530 Subject: [PATCH 008/108] fix: Copy `attached_to_field` and `folder` when amending documents --- frappe/core/doctype/file/test_file.py | 71 +++++++++++++++++++++++++++ frappe/desk/form/load.py | 2 +- frappe/model/document.py | 3 +- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 128d74dae8..fb14b30075 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -641,6 +641,77 @@ class TestAttachment(IntegrationTestCase): self.assertTrue(exists) +class TestCopyAttachmentsFromAmendedFrom(IntegrationTestCase): + """Test that attached_to_field and folder are copied when amending a document.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + from frappe.core.doctype.doctype.test_doctype import new_doctype + + cls.test_doctype = "Test Amendable Attachment" + new_doctype( + cls.test_doctype, + is_submittable=1, + fields=[ + {"label": "Title", "fieldname": "title", "fieldtype": "Data"}, + {"label": "Attachment", "fieldname": "attachment", "fieldtype": "Attach"}, + ], + ).insert(ignore_if_duplicate=True) + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + frappe.delete_doc_if_exists("DocType", cls.test_doctype) + + def test_attached_to_field_and_folder_copied_on_amend(self): + # Create custom folder + custom_folder = frappe.get_doc( + {"doctype": "File", "file_name": "Test Amend Folder", "is_folder": 1, "folder": "Home"} + ).insert() + + # Create original document and attach file with attached_to_field and custom folder + doc = frappe.get_doc(doctype=self.test_doctype, title="Original").insert() + file = frappe.get_doc( + { + "doctype": "File", + "file_name": "amend_test_attach.txt", + "content": "Test Content", + "attached_to_doctype": self.test_doctype, + "attached_to_name": doc.name, + "attached_to_field": "attachment", + "folder": custom_folder.name, + } + ).insert() + + doc.attachment = file.file_url + doc.save() + + # Submit and cancel + doc.submit() + doc.cancel() + + # Amend document + amended_doc = frappe.copy_doc(doc) + amended_doc.docstatus = 0 + amended_doc.amended_from = doc.name + amended_doc.save() + + # Verify copied file has attached_to_field and folder from original + copied_files = frappe.get_all( + "File", + filters={ + "attached_to_doctype": self.test_doctype, + "attached_to_name": amended_doc.name, + "file_name": "amend_test_attach.txt", + }, + fields=["name", "attached_to_field", "folder"], + ) + self.assertEqual(len(copied_files), 1, "Exactly one file should be copied to amended doc") + self.assertEqual(copied_files[0].attached_to_field, "attachment") + self.assertEqual(copied_files[0].folder, custom_folder.name) + + class TestAttachmentsAccess(IntegrationTestCase): def setUp(self) -> None: frappe.db.delete("File", {"is_folder": 0}) diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 87f1954093..1e4af10aea 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -182,7 +182,7 @@ def get_milestones(doctype, name): def get_attachments(dt, dn): return frappe.get_all( "File", - fields=["name", "file_name", "file_url", "is_private"], + fields=["name", "file_name", "file_url", "is_private", "attached_to_field", "folder"], filters={"attached_to_name": str(dn), "attached_to_doctype": dt}, ) diff --git a/frappe/model/document.py b/frappe/model/document.py index 54e7795b62..30f65d9d0e 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -598,7 +598,8 @@ class Document(BaseDocument): "file_name": attach_item.file_name, "attached_to_name": self.name, "attached_to_doctype": self.doctype, - "folder": "Home/Attachments", + "attached_to_field": attach_item.attached_to_field, + "folder": attach_item.folder or "Home/Attachments", "is_private": attach_item.is_private, } ) From 3de1ed26a45434c7c87a9249b528a4c2d2a1fd77 Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Wed, 18 Feb 2026 14:54:52 +0530 Subject: [PATCH 009/108] fix: use autocomplete for showing child table links in web form --- frappe/website/doctype/web_form/web_form.py | 37 ++++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index bd12631577..1939271fef 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -416,7 +416,7 @@ def get_context(context): def load_list_data(self, context): if not self.list_columns: - self.list_columns = get_in_list_view_fields(self.doc_type) + self.list_columns = get_in_list_view_fields(self.doc_type, self.name) context.web_form_doc.list_columns = self.list_columns def load_form_data(self, context): @@ -453,7 +453,7 @@ def get_context(context): # For Table fields, server-side processing for meta for field in context.web_form_doc.web_form_fields: if field.fieldtype == "Table": - field.fields = get_in_list_view_fields(field.options) + field.fields = get_in_list_view_fields(field.options, self.name) if field.fieldtype == "Link": field.fieldtype = "Autocomplete" @@ -794,7 +794,7 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str | # For Table fields, server-side processing for meta for field in out.web_form.web_form_fields: if field.fieldtype == "Table": - field.fields = get_in_list_view_fields(field.options) + field.fields = get_in_list_view_fields(field.options, web_form_name) out.update({field.fieldname: field.fields}) if field.fieldtype == "Link": @@ -807,7 +807,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, web_form_name=None): meta = frappe.get_meta(doctype) fields = [] @@ -824,21 +824,40 @@ def get_in_list_view_fields(doctype): def get_field_df(fieldname): if fieldname == "name": return {"label": "Name", "fieldname": "name", "fieldtype": "Data"} - return meta.get_field(fieldname).as_dict() + df = meta.get_field(fieldname).as_dict() + if df.get("options") and df.get("fieldtype") == "Link": + df["fieldtype"] = "Autocomplete" + df["options"] = get_link_options( + web_form_name, + doctype=df.options, + allow_read_on_all_link_options=df.get("allow_read_on_all_link_options", False), + ) + return df return [get_field_df(f) for f in fields] +def has_link_option(fields, doctype): + for f in fields: + if f.options == doctype: + return True + if hasattr(f, "fields") and isinstance(f.fields, list): + if has_link_option(f.fields, doctype): + return True + return False + + def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=False): web_form: WebForm = frappe.get_lazy_doc("Web Form", web_form_name) if web_form.login_required and frappe.session.user == "Guest": frappe.throw(_("You must be logged in to use this form."), frappe.PermissionError) - if not web_form.published or not any(f for f in web_form.web_form_fields if f.options == doctype): - frappe.throw( - _("You don't have permission to access the {0} DocType.").format(doctype), frappe.PermissionError - ) + if not web_form.published or not has_link_option(web_form.web_form_fields, doctype): + frappe.throw( + _("You don't have permission to access the {0} DocType.").format(doctype), + frappe.PermissionError, + ) link_options, filters = [], {} if web_form.login_required and not allow_read_on_all_link_options: From 3d4a14f7b12daab40b41532da9147306f1a146ed Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Wed, 18 Feb 2026 20:03:38 +0530 Subject: [PATCH 010/108] refactor: cleanup process link field --- frappe/website/doctype/web_form/web_form.py | 36 ++++++++++----------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index 1939271fef..cf480a6d16 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -456,10 +456,7 @@ def get_context(context): field.fields = get_in_list_view_fields(field.options, self.name) if field.fieldtype == "Link": - field.fieldtype = "Autocomplete" - field.options = get_link_options( - self.name, field.options, field.allow_read_on_all_link_options - ) + process_link_field(field, self.name) context.reference_doc = {} @@ -608,6 +605,14 @@ def get_context(context): return permitted_attachments +def process_link_field(field, web_form_name): + field.fieldtype = "Autocomplete" + field.options = get_link_options( + web_form_name, field.options, getattr(field, "allow_read_on_all_link_options", False) + ) + return field + + def get_web_form_module(doc): if doc.is_standard: return get_doc_module(doc.module, doc.doctype, doc.name) @@ -798,10 +803,7 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str | out.update({field.fieldname: field.fields}) if field.fieldtype == "Link": - field.fieldtype = "Autocomplete" - field.options = get_link_options( - web_form_name, field.options, field.allow_read_on_all_link_options - ) + process_link_field(field, web_form_name) return out @@ -824,14 +826,10 @@ def get_in_list_view_fields(doctype, web_form_name=None): def get_field_df(fieldname): if fieldname == "name": return {"label": "Name", "fieldname": "name", "fieldtype": "Data"} + df = meta.get_field(fieldname).as_dict() if df.get("options") and df.get("fieldtype") == "Link": - df["fieldtype"] = "Autocomplete" - df["options"] = get_link_options( - web_form_name, - doctype=df.options, - allow_read_on_all_link_options=df.get("allow_read_on_all_link_options", False), - ) + process_link_field(df, web_form_name) return df return [get_field_df(f) for f in fields] @@ -853,11 +851,11 @@ def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=Fals if web_form.login_required and frappe.session.user == "Guest": frappe.throw(_("You must be logged in to use this form."), frappe.PermissionError) - if not web_form.published or not has_link_option(web_form.web_form_fields, doctype): - frappe.throw( - _("You don't have permission to access the {0} DocType.").format(doctype), - frappe.PermissionError, - ) + if not web_form.published or not has_link_option(web_form.web_form_fields, doctype): + frappe.throw( + _("You don't have permission to access the {0} DocType.").format(doctype), + frappe.PermissionError, + ) link_options, filters = [], {} if web_form.login_required and not allow_read_on_all_link_options: From c97cc1a47164b5bac238b0c25c4bc1b60da76dce Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Thu, 19 Feb 2026 13:03:21 +0530 Subject: [PATCH 011/108] chore: remove unused whitelist decorator --- frappe/website/doctype/web_form/web_form.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index cf480a6d16..629fa58053 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -808,7 +808,6 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str | return out -@frappe.whitelist() def get_in_list_view_fields(doctype, web_form_name=None): meta = frappe.get_meta(doctype) fields = [] From 14159b72f3c03ae3470db5938f4d41b372732d16 Mon Sep 17 00:00:00 2001 From: KerollesFathy Date: Sat, 21 Feb 2026 19:16:19 +0000 Subject: [PATCH 012/108] fix: avoid append None to pages list --- frappe/desk/desktop.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index a2152f63d5..b852102613 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -490,7 +490,9 @@ def get_workspace_sidebar_items(): pages.extend(private_pages) if len(pages) == 0: - pages.append(next((x for x in all_pages if x["title"] == "Welcome Workspace"), None)) + welcome_workspace = next((x for x in all_pages if x["title"] == "Welcome Workspace"), None) + if welcome_workspace: + pages.append(welcome_workspace) return { "pages": pages, From b98396f4f407e1a2e5d66b6c8b230f3ec7de1903 Mon Sep 17 00:00:00 2001 From: Shrihari Mahabal Date: Mon, 23 Feb 2026 11:54:09 +0530 Subject: [PATCH 013/108] fix: fix: add doc button for long doctype name --- frappe/public/js/frappe/list/list_view.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index d9c6514556..6473db896e 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -291,8 +291,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { set_primary_action() { if (this.can_create && !frappe.boot.read_only) { const doctype_name = __(frappe.router.doctype_layout) || __(this.doctype); + const full_label = __("Add {0}", [doctype_name], "Primary action in list view"); const create_button = this.page.set_primary_action( - __("Add {0}", [doctype_name], "Primary action in list view"), + full_label, () => { if (this.settings.primary_action) { this.settings.primary_action(); @@ -304,12 +305,32 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { ); if (frappe.is_mobile()) { create_button.append(__("Add")); + } else { + this._trim_primary_action_if_overflow(create_button, full_label); } } else { this.page.clear_primary_action(); } } + _trim_primary_action_if_overflow(btn, full_label) { + const container = this.page.wrapper.find(".page-head-content")[0]; + if (!container || !btn[0]) return; + const containerRect = container.getBoundingClientRect(); + const btnRect = btn[0].getBoundingClientRect(); + if (btnRect.right > containerRect.right) { + const short_label = __("Add"); + btn.attr("title", full_label) + .tooltip({ delay: { show: 600, hide: 100 }, trigger: "hover" }) + .html( + `${frappe.utils.icon( + "add", + "xs" + )} ` + ); + } + } + make_new_doc() { const doctype = this.doctype; const options = {}; From c859375d2c32372f8e97616cac7beea696124c95 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 20 Feb 2026 20:55:50 +0530 Subject: [PATCH 014/108] feat: module onboarding --- frappe/boot.py | 5 +- frappe/desk/desktop.py | 95 +++--- .../module_onboarding/module_onboarding.json | 28 +- .../module_onboarding/module_onboarding.py | 3 - .../onboarding_step/onboarding_step.json | 5 +- .../workspace_sidebar/workspace_sidebar.json | 9 +- .../workspace_sidebar/workspace_sidebar.py | 1 + frappe/public/js/desk.bundle.js | 1 + frappe/public/js/frappe/form/quick_entry.js | 7 + .../public/js/frappe/ui/sidebar/sidebar.html | 7 + frappe/public/js/frappe/ui/sidebar/sidebar.js | 39 +++ .../ui/user_onboarding/OnboardingPanel.vue | 313 ++++++++++++++++++ .../user_onboarding/user_onboarding.bundle.js | 221 +++++++++++++ frappe/public/scss/desk/sidebar.scss | 15 + 14 files changed, 677 insertions(+), 72 deletions(-) create mode 100644 frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue create mode 100644 frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js diff --git a/frappe/boot.py b/frappe/boot.py index 3eeb7a868a..5042693b77 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -539,7 +539,9 @@ def get_sidebar_items(allowed_workspaces): from frappe import _ from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import auto_generate_sidebar_from_module - workspace_sidebars = frappe.get_all("Workspace Sidebar", fields=["name", "header_icon"]) + workspace_sidebars = frappe.get_all( + "Workspace Sidebar", fields=["name", "header_icon", "module_onboarding"] + ) module_sidebars = auto_generate_sidebar_from_module() workspace_sidebars.extend(module_sidebars) sidebar_items = {} @@ -561,6 +563,7 @@ def get_sidebar_items(allowed_workspaces): "label": sidebar_title, "items": [], "header_icon": sidebar.get("header_icon"), + "module_onboarding": sidebar.get("module_onboarding"), "module": sidebar_doc.module, "app": sidebar_doc.app, } diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index a2152f63d5..e4fdbe98e0 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -51,14 +51,9 @@ class Workspace: self.allowed_pages = get_allowed_pages(cache=True) self.allowed_reports = get_allowed_reports(cache=True) + self.onboarding_list = self.get_onboarding_list() if not minimal: - if self.doc.content: - self.onboarding_list = [ - x["data"]["onboarding_name"] for x in loads(self.doc.content) if x["type"] == "onboarding" - ] - self.onboardings = [] - self.table_counts = get_table_with_counts() self.restricted_doctypes = ( frappe.cache.get_value("domain_restricted_doctypes") or build_domain_restricted_doctype_cache() @@ -67,6 +62,14 @@ class Workspace: frappe.cache.get_value("domain_restricted_pages") or build_domain_restricted_page_cache() ) + def get_onboarding_list(self): + return frappe.get_all( + "Module Onboarding", + filters={"is_complete": 0, "module": self.page_name}, + pluck="name", + order_by="creation", + ) + def is_permitted(self): """Return true if `Has Role` is not set or the user is allowed.""" from frappe.utils import has_common @@ -157,7 +160,6 @@ class Workspace: self.cards = {"items": self.get_links()} self.charts = {"items": self.get_charts()} self.shortcuts = {"items": self.get_shortcuts()} - self.onboardings = {"items": self.get_onboardings()} self.quick_lists = {"items": self.get_quick_lists()} self.number_cards = {"items": self.get_number_cards()} self.custom_blocks = {"items": self.get_custom_blocks()} @@ -315,38 +317,6 @@ class Workspace: return items - @handle_not_exist - def get_onboardings(self): - if self.onboarding_list: - for onboarding in self.onboarding_list: - onboarding_doc = self.get_onboarding_doc(onboarding) - if onboarding_doc: - item = { - "label": _(onboarding), - "title": _(onboarding_doc.title), - "subtitle": _(onboarding_doc.subtitle), - "success": _(onboarding_doc.success_message), - "docs_url": onboarding_doc.documentation_url, - "items": self.get_onboarding_steps(onboarding_doc), - } - self.onboardings.append(item) - return self.onboardings - - @handle_not_exist - def get_onboarding_steps(self, onboarding_doc): - steps = [] - for doc in onboarding_doc.get_steps(): - step = doc.as_dict().copy() - step.label = _(doc.title) - step.description = _(doc.description) - if step.action == "Create Entry": - step.is_submittable = frappe.db.get_value( - "DocType", step.reference_document, "is_submittable", cache=True - ) - steps.append(step) - - return steps - @handle_not_exist def get_number_cards(self): all_number_cards = [] @@ -400,7 +370,6 @@ def get_desktop_page(page: str): "charts": workspace.charts, "shortcuts": workspace.shortcuts, "cards": workspace.cards, - "onboardings": workspace.onboardings, "quick_lists": workspace.quick_lists, "number_cards": workspace.number_cards, "custom_blocks": workspace.custom_blocks, @@ -681,7 +650,7 @@ def prepare_widget(config, doctype, parentfield): @frappe.whitelist() -def update_onboarding_step(name: str | int, field: str, value: int | str): +def update_onboarding_step(name: str, field: str, value: any): """Update status of onboaridng step Args: @@ -700,3 +669,47 @@ def update_onboarding_step(name: str | int, field: str, value: int | str): @frappe.whitelist() def get_installed_apps(): return frappe.get_installed_apps() + + +@frappe.whitelist() +@frappe.read_only() +def get_onboarding_data(module: str): + """Get onboarding data for a page + + Args: + page (string): page name + + Return: + dict: onboarding data + """ + onboardings = [] + onboarding_doc = frappe.get_doc("Module Onboarding", module) + if onboarding_doc.is_complete: + return [] + + item = { + "label": _(module), + "title": _(onboarding_doc.title), + "subtitle": _(onboarding_doc.subtitle), + "success": _(onboarding_doc.success_message), + "docs_url": onboarding_doc.documentation_url, + "items": [], + } + + maps = get_onboarding_step_maps(onboarding_doc.name) + for step in maps: + steps = frappe.get_all("Onboarding Step", filters={"name": step}, order_by="idx", fields=["*"]) + + if steps: + item["items"].append(steps[0]) + + onboardings.append(item) + + if all(step.get("is_complete") or step.get("is_skipped") for step in item["items"]): + return [] + + return onboardings + + +def get_onboarding_step_maps(onboarding): + return frappe.get_all("Onboarding Step Map", filters={"parent": onboarding}, pluck="step", order_by="idx") diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.json b/frappe/desk/doctype/module_onboarding/module_onboarding.json index 4adda25c8e..099c75083e 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.json +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.json @@ -7,12 +7,9 @@ "engine": "InnoDB", "field_order": [ "title", - "subtitle", "module", - "allow_roles", "column_break_4", - "success_message", - "documentation_url", + "allow_roles", "is_complete", "section_break_6", "steps" @@ -25,12 +22,6 @@ "label": "Title", "reqd": 1 }, - { - "fieldname": "subtitle", - "fieldtype": "Data", - "label": "Subtitle", - "reqd": 1 - }, { "fieldname": "module", "fieldtype": "Link", @@ -46,18 +37,6 @@ "fieldname": "section_break_6", "fieldtype": "Section Break" }, - { - "fieldname": "success_message", - "fieldtype": "Data", - "label": "Success Message", - "reqd": 1 - }, - { - "fieldname": "documentation_url", - "fieldtype": "Data", - "label": "Documentation URL", - "reqd": 1 - }, { "default": "0", "fieldname": "is_complete", @@ -82,7 +61,7 @@ } ], "links": [], - "modified": "2024-03-23 16:03:30.074327", + "modified": "2026-02-20 13:30:25.659490", "modified_by": "Administrator", "module": "Desk", "name": "Module Onboarding", @@ -111,8 +90,9 @@ } ], "read_only": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.py b/frappe/desk/doctype/module_onboarding/module_onboarding.py index 3675d08a30..88c67fcbc1 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.py +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.py @@ -19,12 +19,9 @@ class ModuleOnboarding(Document): from frappe.types import DF allow_roles: DF.TableMultiSelect[OnboardingPermission] - documentation_url: DF.Data is_complete: DF.Check module: DF.Link steps: DF.Table[OnboardingStepMap] - subtitle: DF.Data - success_message: DF.Data title: DF.Data # end: auto-generated types diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.json b/frappe/desk/doctype/onboarding_step/onboarding_step.json index b16ee581e4..45efd216ff 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.json +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json @@ -217,7 +217,7 @@ } ], "links": [], - "modified": "2024-03-23 16:03:33.078443", + "modified": "2026-02-21 08:37:30.532549", "modified_by": "Administrator", "module": "Desk", "name": "Onboarding Step", @@ -248,8 +248,9 @@ ], "quick_entry": 1, "read_only": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json index 371c6564df..9b14ba8d4a 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json @@ -13,6 +13,7 @@ "column_break_pukb", "standard", "app", + "module_onboarding", "section_break_vdyo", "items" ], @@ -67,12 +68,18 @@ { "fieldname": "section_break_vdyo", "fieldtype": "Section Break" + }, + { + "fieldname": "module_onboarding", + "fieldtype": "Link", + "label": "Module Onboarding", + "options": "Module Onboarding" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-02-02 12:35:38.009501", + "modified": "2026-02-20 15:19:27.520469", "modified_by": "Administrator", "module": "Desk", "name": "Workspace Sidebar", diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py index b0fe4b5399..8246676e78 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py @@ -29,6 +29,7 @@ class WorkspaceSidebar(Document): for_user: DF.Link | None items: DF.Table[WorkspaceSidebarItem] module: DF.Text | None + module_onboarding: DF.Link | None standard: DF.Check title: DF.Data | None # end: auto-generated types diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 071d0cba84..945a4e5ee2 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -109,6 +109,7 @@ import "./frappe/utils/dashboard_utils.js"; import "./frappe/ui/chart.js"; import "./frappe/ui/datatable.js"; import "./frappe/ui/driver.js"; +import "./frappe/ui/user_onboarding/user_onboarding.bundle.js"; import "./frappe/scanner"; import "./frappe/ui/address_autocomplete/autocomplete_dialog.js"; diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index a2583ac15f..2a051d02fe 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -203,6 +203,13 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm extends frappe.ui.Dialog { let messagetxt = __("{1} saved", [__(me.doctype), me.doc.name.bold()]); me.dialog.animation_speed = "slow"; me.dialog.hide(); + if (frappe.route_hooks.after_save) { + let route_callback = frappe.route_hooks.after_save; + delete frappe.route_hooks.after_save; + + route_callback(me); + } + setTimeout(function () { frappe.show_alert( { diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.html b/frappe/public/js/frappe/ui/sidebar/sidebar.html index 226e968608..7e0d700800 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.html +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.html @@ -40,6 +40,12 @@ Save +

+ + {%= frappe.utils.icon("getting-started" , "sm", "", "", "text-ink-gray-7 current-color", true)%} + {%= __("Continue Onboarding") %} + +

{%= frappe.utils.icon("panel-right-open" , "sm", "", "", "text-ink-gray-7 current-color", true)%} {%= __("Collapse") %} @@ -84,5 +90,6 @@
+
diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.js b/frappe/public/js/frappe/ui/sidebar/sidebar.js index 65b373a58a..281baa107f 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.js @@ -78,6 +78,40 @@ frappe.ui.Sidebar = class Sidebar { } } + setup_onboarding() { + let me = this; + this.$onboarding = this.wrapper.find(".user_onboarding"); + this.$onboarding.empty(); + this.wrapper.find(".onboarding-sidebar").removeClass("hidden"); + + if (this.sidebar_data && this.sidebar_data.module_onboarding) { + return frappe + .call({ + method: "frappe.desk.desktop.get_onboarding_data", + args: { + // send sorted min requirements to increase chance of cache hit + module: this.sidebar_data.module_onboarding, + }, + type: "GET", + }) + .then((data) => { + if (data.message?.length > 0) { + let onboarding_data = data.message[0]; + me.onboarding_widget = new frappe.ui.UserOnboarding({ + title: onboarding_data.title, + steps: onboarding_data.items, + wrapper: me.$onboarding, + header_icon: me.sidebar_header.header_icon, + }); + } else { + this.wrapper.find(".onboarding-sidebar").addClass("hidden"); + } + }); + } else { + this.wrapper.find(".onboarding-sidebar").addClass("hidden"); + } + } + find_nested_items() { const me = this; let currentSection = null; @@ -109,6 +143,11 @@ frappe.ui.Sidebar = class Sidebar { this.sidebar_header = new frappe.ui.SidebarHeader(this); this.make_sidebar(); this.add_sidebar_cards(); + this.setup_onboarding(); + + this.wrapper.find(".onboarding-sidebar").click(() => { + this.setup_onboarding(); + }); } add_card(card) { if (this.cards && this.cards.find((i) => i.title === card.title)) return; diff --git a/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue new file mode 100644 index 0000000000..923a0d7f13 --- /dev/null +++ b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue @@ -0,0 +1,313 @@ + + + diff --git a/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js new file mode 100644 index 0000000000..c167d6df4e --- /dev/null +++ b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js @@ -0,0 +1,221 @@ +import { createApp, ref, h } from "vue"; +import OnboardingPanel from "./OnboardingPanel.vue"; + +class UserOnboarding { + constructor({ title, steps, wrapper, header_icon }) { + this.title = title; + this.steps = steps; + this.$wrapper = $(wrapper); + this.header_icon = header_icon; + this.init(); + } + + init() { + addStyles(); + + let title = this.title || __("Welcome to Frappe!"); + let onboarding_checklist = this.steps || []; + let header_icon = this.header_icon; + + const app = createApp({ + components: { OnboardingPanel }, + + setup() { + const showPanel = ref(true); + const steps = ref(onboarding_checklist); + return () => + h(OnboardingPanel, { + modelValue: showPanel.value, + title: title, + steps: steps.value, + minimizeIcon: frappe.utils.icon("minimize-2", "sm"), + closeIcon: frappe.utils.icon("close", "sm"), + headerIcon: header_icon, + checklistIcon: frappe.utils.icon("circle-check", "sm"), + completeChecklistIcon: frappe.utils.icon( + "circle-check", + "sm", + "", + "", + "", + "", + "var(--green)" + ), + "onUpdate:modelValue": (v) => (showPanel.value = v), + }); + }, + }); + + SetVueGlobals(app); + app.mount(this.$wrapper.get(0)); + } +} + +function addStyles() { + if (document.getElementById("user-onboarding-styles")) return; + + const style = document.createElement("style"); + style.id = "user-onboarding-styles"; + + style.innerHTML = ` + .onb-panel { + position: fixed; + right: 24px; + bottom: 24px; + width: 380px; + max-height: 80vh; + background: #fff; + border-radius: 16px; + box-shadow: 0 12px 40px rgba(0,0,0,0.15); + padding: 16px; + z-index: 9999; + display: flex; + flex-direction: column; + } + + .onb-header-main { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + } + + .onb-header-left { + display: flex; + align-items: center; + gap: 8px; + } + + .onb-header-icon { + width: 24px; + height: 24px; + } + + .onb-header-title { + margin: 0; + font-size: 20px; + font-weight: 600; + } + + .onb-header-actions button { + border: none; + background: transparent; + cursor: pointer; + margin-left: 2px; + } + + .onb-step-left { + display: flex; + align-items: center; + gap: 8px; + flex: 1; /* takes remaining space */ + min-width: 0; /* allows truncation */ + } + + .onb-step-title { + display: flex; + align-items: center; + gap: 8px; + } + + .onb-step-icon { + margin-bottom: 2px; + align-items: center; + } + + .onb-step-text { + white-space: nowrap; + margin-top: 2px; + text-align: left; + font-size: 14px; + } + + .onb-progress { + height: 6px; + background: #eee; + border-radius: 4px; + margin: 12px 0; + } + + .onb-progress-label { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + color: #6b7280; + margin-top: 6px; + } + + .onb-skip { + color: #6b7280; + cursor: pointer; + font-weight: 500; + } + + .onb-skip:hover { + color: #111827; + } + + .onboarding-progress-bar { + height: 100%; + background: #ffcd78; + border-radius: 4px; + } + + .onb-steps { + margin-top: 16px; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + + .onb-group:hover { + color: #111827; + background: #f5f5f5; + } + + .onb-cursor-disabled { + cursor: not-allowed; + } + + .onb-select-cursor { + cursor: pointer; + } + + .onb-show-on-hover { + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease; + } + + .onb-group:hover .onb-show-on-hover { + opacity: 1; + visibility: visible; + } + + .onb-header-logo { + display: flex; + align-items: center; + gap: 8px; + } + + .onb-header-logo img { + width: 24px; + height: 24px; + } + + .onb-header-logo h4 { + margin: 0; + white-space: nowrap; + } + `; + + document.head.appendChild(style); +} + +frappe.provide("frappe.ui"); +frappe.ui.UserOnboarding = UserOnboarding; +export default UserOnboarding; diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index 0f6740c771..739e2c3b16 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -127,6 +127,21 @@ } } + .onboarding-sidebar { + text-decoration: none; + font-size: var(--text-sm); + display: flex; + align-items: center; + svg { + margin: 0px; + flex: 0 0 auto; + } + span { + margin-left: 10px; + @include truncate(); + } + } + .collapse-sidebar-link { text-decoration: none; font-size: var(--text-sm); From 57f673425587349164328809178603d0e4fad7c5 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Mon, 23 Feb 2026 12:59:57 +0530 Subject: [PATCH 015/108] fix(sync): remove `is_standard` notifications records on deletion --- frappe/model/sync.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 12718d813a..b28d3de942 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -200,7 +200,7 @@ def remove_orphan_doctypes(): def remove_orphan_entities(): - entites = ["Workspace", "Dashboard", "Page", "Report"] + entites = ["Workspace", "Dashboard", "Page", "Report", "Notification"] app_level_entities = ["Workspace Sidebar", "Desktop Icon"] entity_filter_map = { "Workspace": [{"public": 1, "module": ["is", "set"], "app": ["is", "set"]}], @@ -209,6 +209,7 @@ def remove_orphan_entities(): "Dashboard": {"is_standard": True}, "Workspace Sidebar": {"standard": True}, "Desktop Icon": {"standard": True}, + "Notification": {"is_standard": True}, } entity_file_map = create_entity_file_map(entites) From d8c1e9400557e31e34cedad8da8787c861950246 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 23 Feb 2026 14:17:05 +0530 Subject: [PATCH 016/108] fix: notification panel shadow --- frappe/public/scss/desk/notification.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/scss/desk/notification.scss b/frappe/public/scss/desk/notification.scss index ba919797cc..c5ef9ea68f 100644 --- a/frappe/public/scss/desk/notification.scss +++ b/frappe/public/scss/desk/notification.scss @@ -60,7 +60,7 @@ min-height: 480px; position: absolute; top: 0; - box-shadow: var(--shadow-2xl); + box-shadow: rgba(0, 0, 0, 0.1) 8px 0px 8px; background-color: var(--bg-color); height: 100vh; overflow: hidden; From 29bbb183a1c01577006b61e4c8eda192fe18fb34 Mon Sep 17 00:00:00 2001 From: Sumit Jain <59503001+sumitjain236@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:36:33 +0530 Subject: [PATCH 017/108] fix: duplicate last quoted messagg (#37240) --- frappe/public/js/frappe/views/communication.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 34871f275b..bfa1706f11 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -5,6 +5,8 @@ import localforage from "localforage"; frappe.last_edited_communication = {}; const separator_element = "
---
"; +// Quill uses

---

; match both when stripping quoted content +const separator_regex = /<(?:div|p)(?:\s[^>]*)?>---<\/(?:div|p)>/i; frappe.views.CommunicationComposer = class { constructor(opts) { @@ -566,6 +568,18 @@ frappe.views.CommunicationComposer = class { const last_edited = this.get_last_edited_communication(); if (!last_edited.content && !last_edited.html_content) return; + // For replies: strip duplicate quoted content (Quill uses

---

) + if (this.is_a_reply) { + const reply_block = this.get_earlier_reply(); + for (const field of ["content", "html_content"]) { + if (last_edited[field]) { + last_edited[field] = + (last_edited[field].split(separator_regex)[0] || "").trimEnd() + + reply_block; + } + } + } + // prevent re-triggering of email template if (last_edited.email_template) { const template_field = this.dialog.fields_dict.email_template; @@ -789,7 +803,7 @@ frappe.views.CommunicationComposer = class { save_as_draft() { if (this.dialog && this.frm) { let message = this.get_email_content(); - message = message.split(separator_element)[0]; + message = message.split(separator_regex)[0]; this.save_item_in_local_forage(this.frm.doctype + this.frm.docname, message); this.save_item_in_local_forage( this.frm.doctype + this.frm.docname + "_use_html", @@ -950,7 +964,7 @@ frappe.views.CommunicationComposer = class { } if (this.is_a_reply && !this.reply_set) { - message += this.get_earlier_reply(); + message = message.split(separator_regex)[0] + this.get_earlier_reply(); } await this.set_email_content(message); From bbf8f4d75b7f5e9dcc3f2651b9b10c3dcb2a130f Mon Sep 17 00:00:00 2001 From: Safwan Samsudeen Date: Mon, 23 Feb 2026 14:41:36 +0530 Subject: [PATCH 018/108] fix: add id to file preview --- frappe/core/doctype/file/file.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index f3f1380855..6495aa4c1b 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -54,14 +54,14 @@ frappe.ui.form.on("File", { `); } else if (frappe.utils.is_video_file(frm.doc.file_url)) { $preview = $(`
`); @@ -72,14 +72,16 @@ frappe.ui.form.on("File", { style="background:#323639;" width="100%" height="1190" - src="${frappe.utils.escape_html(frm.doc.file_url)}" type="application/pdf" + src="${frappe.utils.escape_html(frm.doc.file_url + "?fid=" + frm.doc.name)}" type="application/pdf" > `); } else if (file_extension === "mp3") { $preview = $(`
`); From 967edf99806c94fec83356c413310d4c8b7fe66d Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 23 Feb 2026 15:09:06 +0530 Subject: [PATCH 019/108] fix: user action designs --- .../js/frappe/form/sidebar/form_sidebar.js | 19 +++-- .../frappe/form/templates/form_sidebar.html | 10 ++- frappe/public/scss/desk/form_sidebar.scss | 80 +++++++++++++++++++ 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/frappe/public/js/frappe/form/sidebar/form_sidebar.js b/frappe/public/js/frappe/form/sidebar/form_sidebar.js index 29f26b76db..ab02e96ab1 100644 --- a/frappe/public/js/frappe/form/sidebar/form_sidebar.js +++ b/frappe/public/js/frappe/form/sidebar/form_sidebar.js @@ -26,6 +26,7 @@ frappe.ui.form.Sidebar = class { .appendTo(this.page.sidebar.empty()); this.user_actions = this.sidebar.find(".user-actions"); + this.user_actions_list = this.sidebar.find(".user-actions-list"); this.image_section = this.sidebar.find(".sidebar-image-section"); this.image_wrapper = this.image_section.find(".sidebar-image-wrapper"); this.make_assignments(); @@ -245,19 +246,23 @@ frappe.ui.form.Sidebar = class { } add_user_action(label, click) { - return $("
") - .html(label) - .appendTo( - $('
').appendTo( - this.user_actions.removeClass("hidden") - ) + const parent = this.user_actions_list.length ? this.user_actions_list : this.user_actions; + this.user_actions.removeClass("hidden"); + const row = $('
').appendTo(parent); + + return $('
') + .html( + `${label} + ${frappe.utils.icon("external-link", "sm")}` ) + .appendTo(row) .on("click", click); } clear_user_actions() { this.user_actions.addClass("hidden"); - this.user_actions.find(".user-action-row").remove(); + const parent = this.user_actions_list.length ? this.user_actions_list : this.user_actions; + parent.find(".user-action-row").remove(); } refresh_image() {} diff --git a/frappe/public/js/frappe/form/templates/form_sidebar.html b/frappe/public/js/frappe/form/templates/form_sidebar.html index a43117a67d..30446fe72b 100644 --- a/frappe/public/js/frappe/form/templates/form_sidebar.html +++ b/frappe/public/js/frappe/form/templates/form_sidebar.html @@ -1,4 +1,3 @@ - + diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.js b/frappe/public/js/frappe/ui/sidebar/sidebar.js index 281baa107f..84f4665ffd 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.js @@ -78,26 +78,49 @@ frappe.ui.Sidebar = class Sidebar { } } - setup_onboarding() { - let me = this; - this.$onboarding = this.wrapper.find(".user_onboarding"); + remove_onboarding_wrapper() { this.$onboarding.empty(); this.wrapper.find(".onboarding-sidebar").removeClass("hidden"); + } + + setup_onboarding() { + let me = this; + this.$onboarding = this.wrapper.find(".user-onboarding"); + + if (!this.sidebar_data || !this.sidebar_data.module_onboarding) { + this.remove_onboarding_wrapper(); + return; + } + + let module_name = this.sidebar_data.module_onboarding; + + if (this?.onboarding_widget[module_name]) { + return; + } + + this.remove_onboarding_wrapper(); + if (module_name) { + if ( + this?.onboarding_widget[module_name] && + this.onboarding_widget[module_name].hide_panel + ) { + return; + } - if (this.sidebar_data && this.sidebar_data.module_onboarding) { return frappe .call({ method: "frappe.desk.desktop.get_onboarding_data", args: { // send sorted min requirements to increase chance of cache hit - module: this.sidebar_data.module_onboarding, + module: module_name, }, type: "GET", }) .then((data) => { if (data.message?.length > 0) { let onboarding_data = data.message[0]; - me.onboarding_widget = new frappe.ui.UserOnboarding({ + me.onboarding_widget = {}; + me.onboarding_widget[module_name] = new frappe.ui.UserOnboarding({ title: onboarding_data.title, steps: onboarding_data.items, wrapper: me.$onboarding, @@ -133,6 +156,10 @@ frappe.ui.Sidebar = class Sidebar { this.workspace_sidebar_items = updated_items; } setup(workspace_title) { + if (!this.onboarding_widget) { + this.onboarding_widget = {}; + } + $(document).trigger("sidebar_setup", { sidebar: this }); this.sidebar_title = workspace_title; this.check_for_private_workspace(workspace_title); @@ -146,6 +173,10 @@ frappe.ui.Sidebar = class Sidebar { this.setup_onboarding(); this.wrapper.find(".onboarding-sidebar").click(() => { + if (this.sidebar_data?.module_onboarding) { + delete this.onboarding_widget[this.sidebar_data.module_onboarding]; + } + this.setup_onboarding(); }); } diff --git a/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue index 923a0d7f13..68150e9744 100644 --- a/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue +++ b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue @@ -45,6 +45,8 @@ const visible = computed({ set: (val) => emit("update:modelValue", val), }); +let skippAll = false; + const completedCount = computed( () => props.steps.filter((step) => step.is_complete || step.is_skipped).length ); @@ -68,10 +70,27 @@ function skipAll(skips) { markSkip(step); } }); + + skippAll = true; +} + +function resetAll(skips) { + skips.forEach((step) => { + if (!step.is_complete && step.is_skipped) { + markReset(step); + } + }); + + skippAll = false; } function handleAction(step) { if (step.is_complete) return; + if (step.is_skipped) return; + + if (step.route_options && typeof step.route_options === "string") { + frappe.route_options = JSON.parse(step.route_options); + } const actions = { "Create Entry": createEntry, @@ -79,6 +98,7 @@ function handleAction(step) { "Update Settings": updateSettings, "View Report": openReport, "Go to Page": goToPage, + "View Docs": viewDocs, }; if (step.action && actions[step.action]) { @@ -88,13 +108,22 @@ function handleAction(step) { } } +function viewDocs(step) { + window.open(step.path, "_blank"); + markComplete(step); +} + function goToPage(step) { + toggleCollapse(); + frappe.set_route(step.path).then(() => { markComplete(step); }); } function openReport(step) { + toggleCollapse(); + const route = frappe.utils.generate_route({ name: step.reference_report, type: "report", @@ -162,7 +191,6 @@ async function createEntry(step) { }; frappe.route_hooks.after_save = callback; - if (step.show_full_form) { frappe.set_route("Form", step.reference_document, "new"); } else { @@ -204,31 +232,42 @@ function markReset(step) {