feat: module onboarding

This commit is contained in:
Rohit Waghchaure 2026-02-20 20:55:50 +05:30
parent 20952ebe00
commit c859375d2c
14 changed files with 677 additions and 72 deletions

View file

@ -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,
}

View file

@ -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")

View file

@ -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
}
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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",

View file

@ -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

View file

@ -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";

View file

@ -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(
{

View file

@ -40,6 +40,12 @@
Save
</button>
</div>
<p>
<a class="onboarding-sidebar">
{%= frappe.utils.icon("getting-started" , "sm", "", "", "text-ink-gray-7 current-color", true)%}
<span> {%= __("Continue Onboarding") %} </span>
</a>
</p>
<a class="collapse-sidebar-link">
{%= frappe.utils.icon("panel-right-open" , "sm", "", "", "text-ink-gray-7 current-color", true)%}
<span> {%= __("Collapse") %} </span>
@ -84,5 +90,6 @@
</div>
</div>
<div class="overlay" style="z-index: 1021;"></div>
<div class="user_onboarding"></div>
</div>
</div>

View file

@ -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;

View file

@ -0,0 +1,313 @@
<script setup>
import { ref, computed } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
title: {
type: String,
default: "Welcome",
},
steps: {
type: Array,
default: () => [],
},
minimizeIcon: {
type: String,
default: "—",
},
closeIcon: {
type: String,
default: "✕",
},
headerIcon: {
type: String,
default: "👋",
},
checklistIcon: {
type: String,
default: "✔",
},
completeChecklistIcon: {
type: String,
default: "✔",
},
});
const emit = defineEmits(["update:modelValue", "skip"]);
const collapsed = ref(false);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
const completedCount = computed(
() => props.steps.filter((step) => step.is_complete || step.is_skipped).length
);
const progress = computed(() => {
if (!props.steps.length) return 0;
return Math.round((completedCount.value / props.steps.length) * 100);
});
function close() {
visible.value = false;
}
function toggleCollapse() {
collapsed.value = !collapsed.value;
}
function skipAll(skips) {
skips.forEach((step) => {
if (!step.is_complete && !step.is_skipped) {
markSkip(step);
}
});
}
function handleAction(step) {
if (step.is_complete) return;
const actions = {
"Create Entry": createEntry,
"Show Form Tour": showFormTour,
"Update Settings": updateSettings,
"View Report": openReport,
"Go to Page": goToPage,
};
if (step.action && actions[step.action]) {
actions[step.action](step);
} else if (step.route) {
frappe.set_route(step.route);
}
}
function goToPage(step) {
frappe.set_route(step.path).then(() => {
markComplete(step);
});
}
function openReport(step) {
const route = frappe.utils.generate_route({
name: step.reference_report,
type: "report",
is_query_report: step.report_type !== "Report Builder",
doctype: step.report_reference_doctype,
});
frappe.set_route(route).then(() => {
markComplete(step);
});
}
function showFormTour(step) {
let route = step.is_single
? frappe.router.slug(step.reference_document)
: `${frappe.router.slug(step.reference_document)}/new`;
frappe.route_hooks = {};
frappe.route_hooks.after_load = (frm) => {
const tour_name = step.form_tour;
on_finish = () => markComplete(step);
frm.tour
.init({ tour_name, on_finish: () => markComplete(step) })
.then(() => frm.tour.start());
};
frappe.set_route(route);
}
function updateSettings(step) {
frappe.route_hooks = {};
frappe.route_hooks.after_load = (frm) => {
frm.scroll_to_field(step.field);
frm.doc.__unsaved = true;
};
frappe.route_hooks.after_save = (frm) => {
const success = frm.doc[step.field] == step.value_to_validate;
if (success) {
markComplete(step);
}
};
frappe.set_route("Form", step.reference_document);
}
async function createEntry(step) {
toggleCollapse();
frappe.route_hooks = {};
frappe.route_hooks.after_load = (frm) => {
const tour_name = step.form_tour;
if (tour_name) {
on_finish = () => {
console.log("Tour finished");
};
frm.tour.init({ tour_name, on_finish }).then(() => frm.tour.start());
}
};
const callback = () => {
markComplete(step);
};
frappe.route_hooks.after_save = callback;
if (step.show_full_form) {
frappe.set_route("Form", step.reference_document, "new");
} else {
frappe.new_doc(step.reference_document);
}
}
function markComplete(step) {
step.is_complete = true;
frappe.call("frappe.desk.desktop.update_onboarding_step", {
name: step.name,
field: "is_complete",
value: 1,
});
}
function markSkip(step) {
step.is_skipped = true;
frappe.call("frappe.desk.desktop.update_onboarding_step", {
name: step.name,
field: "is_skipped",
value: 1,
});
}
function markReset(step) {
step.is_skipped = false;
frappe.call("frappe.desk.desktop.update_onboarding_step", {
name: step.name,
field: "is_skipped",
value: 0,
});
}
</script>
<template>
<div v-if="visible" class="onb-panel">
<!-- Header -->
<div class="header onb-header-main">
<div class="onb-header-left">
<div class="onb-header-logo" v-html="headerIcon"></div>
<h4 class="onb-header-title">{{ title }}</h4>
</div>
<div class="onb-header-actions">
<button @click="toggleCollapse" v-html="minimizeIcon"></button>
<button @click="close" v-html="closeIcon"></button>
</div>
</div>
<!-- Body -->
<div v-if="!collapsed" class="body">
<div class="intro">
<p>{{ completedCount }}/{{ steps.length }} steps completed</p>
</div>
<div class="onb-progress">
<div class="onboarding-progress-bar" :style="{ width: progress + '%' }"></div>
</div>
<div class="onb-progress-label">
{{ progress }}% completed
<span class="onb-skip" @click="skipAll(steps)">Skip all</span>
</div>
<!-- Steps -->
<div class="onb-steps flex flex-col gap-2.5 overflow-hidden">
<div
style="width: 100%"
v-for="(step, i) in steps"
:key="i"
:class="{ is_complete: step.is_complete }"
>
<div
class="onb-group w-full step-title flex items-center"
style="align-items: center"
:class="
step.is_complete
? 'text-extra-muted onb-cursor-disabled'
: 'text-ink-gray-8 onb-select-cursor'
"
>
<div class="onb-step-left">
<div class="onb-step-icon" v-if="step.is_complete">
<div v-html="completeChecklistIcon"></div>
</div>
<div class="onb-step-icon" v-else>
<div v-html="checklistIcon"></div>
</div>
<div v-if="!step.is_skipped">
<span class="text-base onb-step-text" @click="handleAction(step)">
{{ step.action_label }}
</span>
</div>
<div v-else>
<span
class="text-base onb-step-text"
style="text-decoration-line: line-through"
>
{{ step.action_label }}
</span>
</div>
</div>
<div v-if="!step.is_complete">
<div v-if="!step.is_skipped">
<div
class="ml-auto text-base onb-show-on-hover text-sm w-12 text-right text-ink-gray-8"
>
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 10px;
"
@click="markSkip(step)"
>
{{ __("Skip") }}
</span>
</div>
</div>
<div v-if="step.is_skipped">
<div
class="ml-auto text-base onb-show-on-hover text-sm w-12 text-right text-ink-gray-8"
>
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 10px;
"
@click="markReset(step)"
>
{{ __("Reset") }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -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;

View file

@ -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);