From c859375d2c32372f8e97616cac7beea696124c95 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 20 Feb 2026 20:55:50 +0530 Subject: [PATCH] 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);