feat: module onboarding
This commit is contained in:
parent
20952ebe00
commit
c859375d2c
14 changed files with 677 additions and 72 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
313
frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
Normal file
313
frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue