Merge branch 'develop' into fix-new-linked-document
This commit is contained in:
commit
f384f5df5e
104 changed files with 34672 additions and 22977 deletions
|
|
@ -72,10 +72,8 @@ context("Web Form", () => {
|
|||
|
||||
cy.call("logout");
|
||||
|
||||
cy.visit("/note");
|
||||
cy.get_open_dialog()
|
||||
.get(".modal-message")
|
||||
.contains("You are not permitted to access this page without login.");
|
||||
cy.visit("/note", { failOnStatusCode: false });
|
||||
cy.contains("You must be logged in to use this form.");
|
||||
});
|
||||
|
||||
it("Show List", () => {
|
||||
|
|
|
|||
|
|
@ -253,7 +253,11 @@ class LoginManager:
|
|||
):
|
||||
return
|
||||
|
||||
clear_sessions(frappe.session.user, keep_current=True)
|
||||
clear_sessions(
|
||||
frappe.session.user,
|
||||
keep_current=True,
|
||||
force=frappe.session.user != "Administrator",
|
||||
)
|
||||
|
||||
def authenticate(self, user: str | None = None, pwd: str | None = None):
|
||||
from frappe.core.doctype.user.user import User
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1116,6 +1116,7 @@ class TestGunicornWorker(IntegrationTestCase):
|
|||
time.sleep(2)
|
||||
execute_in_shell("pgrep gunicorn | xargs -L1 kill -9")
|
||||
|
||||
@unittest.skip("Flaky test")
|
||||
def test_gunicorn_ping_sync(self):
|
||||
self.spawn_gunicorn()
|
||||
path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping"
|
||||
|
|
@ -1126,6 +1127,7 @@ class TestGunicornWorker(IntegrationTestCase):
|
|||
path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping"
|
||||
self.assertEqual(requests.get(path).status_code, 200)
|
||||
|
||||
@unittest.skip("Flaky test")
|
||||
def test_gunicorn_idle_cpu_usage(self):
|
||||
def get_total_usage():
|
||||
process = psutil.Process(self.handle.pid)
|
||||
|
|
|
|||
|
|
@ -992,7 +992,13 @@ class DocType(Document):
|
|||
self.append("fields", {"label": "Is Group", "fieldtype": "Check", "fieldname": "is_group"})
|
||||
self.append(
|
||||
"fields",
|
||||
{"label": "Old Parent", "fieldtype": "Link", "options": self.name, "fieldname": "old_parent"},
|
||||
{
|
||||
"label": "Old Parent",
|
||||
"fieldtype": "Link",
|
||||
"options": self.name,
|
||||
"fieldname": "old_parent",
|
||||
"hidden": 1,
|
||||
},
|
||||
)
|
||||
|
||||
parent_field_label = f"Parent {self.name}"
|
||||
|
|
|
|||
|
|
@ -48,20 +48,22 @@ frappe.ui.form.on("File", {
|
|||
preview_file: function (frm) {
|
||||
let $preview = "";
|
||||
let file_extension = frm.doc.file_type.toLowerCase();
|
||||
const full_file_url = frm.doc.file_url + "?fid=" + frm.doc.name;
|
||||
const src_url = frappe.utils.escape_html(full_file_url);
|
||||
|
||||
if (frappe.utils.is_image_file(frm.doc.file_url)) {
|
||||
if (frappe.utils.is_image_file(full_file_url)) {
|
||||
$preview = $(`<div class="img_preview">
|
||||
<img
|
||||
class="img-responsive"
|
||||
style="max-width: 500px";
|
||||
src="${frappe.utils.escape_html(frm.doc.file_url)}"
|
||||
src="${src_url}"
|
||||
onerror="${frm.toggle_display("preview", false)}"
|
||||
/>
|
||||
</div>`);
|
||||
} else if (frappe.utils.is_video_file(frm.doc.file_url)) {
|
||||
} else if (frappe.utils.is_video_file(full_file_url)) {
|
||||
$preview = $(`<div class="img_preview">
|
||||
<video width="480" height="320" controls>
|
||||
<source src="${frappe.utils.escape_html(frm.doc.file_url)}">
|
||||
<source src="${src_url}">
|
||||
${__("Your browser does not support the video element.")}
|
||||
</video>
|
||||
</div>`);
|
||||
|
|
@ -72,14 +74,14 @@ 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="${src_url}" type="application/pdf"
|
||||
>
|
||||
</object>
|
||||
</div>`);
|
||||
} else if (file_extension === "mp3") {
|
||||
$preview = $(`<div class="img_preview">
|
||||
<audio width="480" height="60" controls>
|
||||
<source src="${frappe.utils.escape_html(frm.doc.file_url)}" type="audio/mpeg">
|
||||
<source src="${src_url}" type="audio/mpeg">
|
||||
${__("Your browser does not support the audio element.")}
|
||||
</audio >
|
||||
</div>`);
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -93,20 +93,23 @@ class Report(Document):
|
|||
doc.prepared_report = 0
|
||||
|
||||
def on_trash(self):
|
||||
if (
|
||||
self.is_standard == "Yes"
|
||||
and not cint(getattr(frappe.local.conf, "developer_mode", 0))
|
||||
and not frappe.flags.in_migrate
|
||||
and not frappe.flags.in_patch
|
||||
):
|
||||
frappe.throw(_("You are not allowed to delete Standard Report"))
|
||||
if self.is_standard == "Yes":
|
||||
if (
|
||||
not cint(getattr(frappe.local.conf, "developer_mode", 0))
|
||||
and not frappe.flags.in_migrate
|
||||
and not frappe.flags.in_patch
|
||||
):
|
||||
frappe.throw(_("You are not allowed to delete Standard Report"))
|
||||
|
||||
if frappe.conf.developer_mode and not frappe.flags.in_test:
|
||||
frappe.db.after_commit(self.delete_report_folder)
|
||||
|
||||
delete_custom_role("report", self.name)
|
||||
|
||||
def after_delete(self):
|
||||
def delete_report_folder(self):
|
||||
from frappe.modules.export_file import delete_folder
|
||||
|
||||
if not frappe.flags.in_test and frappe.conf.developer_mode:
|
||||
delete_folder(self.module, "Report", self.name)
|
||||
delete_folder(self.module, "Report", self.name)
|
||||
|
||||
def get_permission_log_options(self, event=None):
|
||||
return {"fields": ["roles"]}
|
||||
|
|
|
|||
|
|
@ -248,7 +248,6 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Note: Multiple sessions will be allowed in case of mobile device",
|
||||
"fieldname": "deny_multiple_sessions",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow only one session per user"
|
||||
|
|
@ -790,7 +789,7 @@
|
|||
"icon": "fa fa-cog",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-02 18:13:45.430712",
|
||||
"modified": "2026-02-24 14:27:04.763075",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "System Settings",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Translation", {
|
||||
refresh: function () {
|
||||
//
|
||||
refresh: function (frm) {
|
||||
frm.set_intro(
|
||||
__(
|
||||
"Translations can be viewed by guests, avoid storing private details in translations."
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -393,6 +393,9 @@ class User(Document):
|
|||
"""Set as System User if any of the given roles has desk_access"""
|
||||
self.user_type = "System User" if self.has_desk_access() else "Website User"
|
||||
|
||||
if self.has_value_changed("user_type"):
|
||||
clear_sessions(user=self.name, force=True)
|
||||
|
||||
def set_roles_and_modules_based_on_user_type(self):
|
||||
user_type_doc = frappe.get_cached_doc("User Type", self.user_type)
|
||||
if user_type_doc.role:
|
||||
|
|
@ -427,15 +430,6 @@ class User(Document):
|
|||
self.doctype, self.name, self.name, write=1, share=1, flags={"ignore_share_permission": True}
|
||||
)
|
||||
|
||||
def validate_share(self, docshare):
|
||||
pass
|
||||
# if docshare.user == self.name:
|
||||
# if self.user_type=="System User":
|
||||
# if docshare.share != 1:
|
||||
# frappe.throw(_("Sorry! User should have complete access to their own record."))
|
||||
# else:
|
||||
# frappe.throw(_("Sorry! Sharing with Website User is prohibited."))
|
||||
|
||||
def send_password_notification(self, new_password):
|
||||
try:
|
||||
if self.flags.in_insert:
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ class Workspace:
|
|||
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 = (
|
||||
|
|
@ -157,7 +156,7 @@ 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.onboardings = {"items": []}
|
||||
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 +314,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 = []
|
||||
|
|
@ -490,7 +457,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,
|
||||
|
|
@ -700,3 +669,50 @@ 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 []
|
||||
|
||||
# Check if user is allowed
|
||||
allowed_roles = set(onboarding_doc.get_allowed_roles())
|
||||
user_roles = set(frappe.get_roles())
|
||||
if not allowed_roles & user_roles:
|
||||
return None
|
||||
|
||||
item = {
|
||||
"label": _(module),
|
||||
"title": _(onboarding_doc.title),
|
||||
"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
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
"default": "1",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled System Notification"
|
||||
"label": "Enable System Notification"
|
||||
},
|
||||
{
|
||||
"fieldname": "subscribed_documents",
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-17 13:39:35.159083",
|
||||
"modified": "2026-02-24 11:06:24.112935",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "Notification Settings",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@
|
|||
"validate_action",
|
||||
"field",
|
||||
"value_to_validate",
|
||||
"video_url"
|
||||
"video_url",
|
||||
"section_break_ajog",
|
||||
"route_options"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -61,7 +63,7 @@
|
|||
"fieldname": "action",
|
||||
"fieldtype": "Select",
|
||||
"label": "Action",
|
||||
"options": "Create Entry\nUpdate Settings\nShow Form Tour\nView Report\nGo to Page\nWatch Video",
|
||||
"options": "Create Entry\nUpdate Settings\nShow Form Tour\nView Report\nGo to Page\nView Docs",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
|
|
@ -145,7 +147,7 @@
|
|||
"label": "Is Single"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.action == \"Go to Page\"",
|
||||
"depends_on": "eval:doc.action == \"Go to Page\" || doc.action === \"View Docs\"",
|
||||
"description": "Example: #Tree/Account",
|
||||
"fieldname": "path",
|
||||
"fieldtype": "Data",
|
||||
|
|
@ -214,10 +216,19 @@
|
|||
"fieldtype": "Link",
|
||||
"label": "Form Tour",
|
||||
"options": "Form Tour"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ajog",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "route_options",
|
||||
"fieldtype": "Code",
|
||||
"label": "Route Options"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:03:33.078443",
|
||||
"modified": "2026-02-23 21:03:51.131292",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "Onboarding Step",
|
||||
|
|
@ -248,8 +259,9 @@
|
|||
],
|
||||
"quick_entry": 1,
|
||||
"read_only": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class OnboardingStep(Document):
|
|||
from frappe.types import DF
|
||||
|
||||
action: DF.Literal[
|
||||
"Create Entry", "Update Settings", "Show Form Tour", "View Report", "Go to Page", "Watch Video"
|
||||
"Create Entry", "Update Settings", "Show Form Tour", "View Report", "Go to Page", "View Docs"
|
||||
]
|
||||
action_label: DF.Data | None
|
||||
callback_message: DF.SmallText | None
|
||||
|
|
@ -36,6 +36,7 @@ class OnboardingStep(Document):
|
|||
report_description: DF.Data | None
|
||||
report_reference_doctype: DF.Data | None
|
||||
report_type: DF.Data | None
|
||||
route_options: DF.Code | None
|
||||
show_form_tour: DF.Check
|
||||
show_full_form: DF.Check
|
||||
title: DF.Data
|
||||
|
|
|
|||
|
|
@ -76,6 +76,18 @@ class Workspace(Document):
|
|||
|
||||
if self.public and not is_workspace_manager() and not disable_saving_as_public():
|
||||
frappe.throw(_("You need to be Workspace Manager to edit this document"))
|
||||
|
||||
if (
|
||||
not self.public
|
||||
and self.for_user
|
||||
and self.for_user != frappe.session.user
|
||||
and not is_workspace_manager()
|
||||
):
|
||||
frappe.throw(
|
||||
_("You are not allowed to edit this workspace"),
|
||||
frappe.PermissionError,
|
||||
)
|
||||
|
||||
if self.has_value_changed("title"):
|
||||
validate_route_conflict(self.doctype, self.title)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -185,7 +185,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},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
padding:0px;
|
||||
margin: 0px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.icons{
|
||||
gap: 16px;
|
||||
|
|
@ -109,6 +109,10 @@
|
|||
gap: 12px;
|
||||
padding: 13px 16px 12px 16px;
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
border-width: 1px;
|
||||
border-style: dashed;
|
||||
border-color: transparent;
|
||||
}
|
||||
.desktop-icon.desktop-edit-mode .hide-button {
|
||||
display: flex;
|
||||
|
|
@ -129,6 +133,7 @@
|
|||
.icon-container{
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -254,13 +259,13 @@
|
|||
position: absolute;
|
||||
}
|
||||
|
||||
.folder-icon{
|
||||
border-radius: 10px;
|
||||
background-color: var(--folder-icon-background-color) !important;
|
||||
.folder-icon {
|
||||
border-radius: 16px;
|
||||
background-color: var(--folder-icon-background-color) !important;
|
||||
box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.14);
|
||||
padding: 7px;
|
||||
align-items: normal;
|
||||
box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.14);
|
||||
/* box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.14); */
|
||||
& .icons{
|
||||
gap: 2.1px;
|
||||
margin-top: 0px;
|
||||
|
|
@ -343,8 +348,7 @@
|
|||
}
|
||||
|
||||
.desktop-edit-mode{
|
||||
border: 1px dashed var(--outline-gray-2);
|
||||
border-radius: 20px;
|
||||
border-color: var(--outline-gray-2);
|
||||
}
|
||||
.edit-mode-buttons{
|
||||
display: none;
|
||||
|
|
@ -443,6 +447,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.icons-container {
|
||||
> .icons-container {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-edit{
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
|
|
|||
|
|
@ -286,7 +286,6 @@ class DesktopPage {
|
|||
this.setup_navbar();
|
||||
this.setup_awesomebar();
|
||||
this.handle_route_change();
|
||||
this.setup_edit_button();
|
||||
}
|
||||
setup_edit_button() {
|
||||
if (this.edit_mode || frappe.is_mobile()) return;
|
||||
|
|
|
|||
|
|
@ -514,7 +514,18 @@ def send_now(name: str | int, force_send: bool = False):
|
|||
@frappe.whitelist()
|
||||
def toggle_sending(enable: bool | int | str):
|
||||
frappe.only_for("System Manager")
|
||||
frappe.db.set_default("suspend_email_queue", 0 if sbool(enable) else 1)
|
||||
suspend_value = 0 if sbool(enable) else 1
|
||||
frappe.db.set_default("suspend_email_queue", suspend_value)
|
||||
|
||||
action = "Resumed" if suspend_value == 0 else "Suspended"
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Activity Log",
|
||||
"user": frappe.session.user,
|
||||
"status": "Success",
|
||||
"subject": f"Email Queue sending {action.lower()}",
|
||||
}
|
||||
).insert(ignore_permissions=True, ignore_links=True)
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
|
|
|
|||
|
|
@ -721,23 +721,22 @@ def get_context(context):
|
|||
self.message = self.get_template(md_as_html=True)
|
||||
|
||||
def on_trash(self):
|
||||
# Prevent deletion of standard notifications outside developer mode to avoid restoration during migration
|
||||
if (
|
||||
self.is_standard
|
||||
and not frappe.conf.developer_mode
|
||||
and not frappe.flags.in_migrate
|
||||
and not frappe.flags.in_patch
|
||||
):
|
||||
frappe.throw(
|
||||
_("You are not allowed to delete a standard Notification. You can disable it instead.")
|
||||
)
|
||||
if self.is_standard:
|
||||
# Prevent deletion of standard notifications outside developer mode to avoid restoration during migration
|
||||
if not frappe.conf.developer_mode and not frappe.flags.in_migrate and not frappe.flags.in_patch:
|
||||
frappe.throw(
|
||||
_("You are not allowed to delete a standard Notification. You can disable it instead.")
|
||||
)
|
||||
|
||||
if frappe.conf.developer_mode and not frappe.flags.in_test:
|
||||
frappe.db.after_commit(self.delete_notification_folder)
|
||||
|
||||
clear_notification_cache()
|
||||
|
||||
def after_delete(self):
|
||||
def delete_notification_folder(self):
|
||||
from frappe.modules.export_file import delete_folder
|
||||
|
||||
if not frappe.flags.in_test and frappe.conf.developer_mode:
|
||||
delete_folder(self.module, "Notification", self.name)
|
||||
delete_folder(self.module, "Notification", self.name)
|
||||
|
||||
|
||||
def clear_notification_cache():
|
||||
|
|
|
|||
|
|
@ -478,46 +478,59 @@ def inline_style_in_html(html, add_css=True):
|
|||
|
||||
def add_attachment(fname, fcontent, content_type=None, parent=None, content_id=None, inline=False):
|
||||
"""Add attachment to parent which must an email object"""
|
||||
|
||||
import mimetypes
|
||||
from email import encoders
|
||||
from email.mime.audio import MIMEAudio
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
if not content_type:
|
||||
content_type, _encoding = mimetypes.guess_type(fname)
|
||||
|
||||
if not parent:
|
||||
return
|
||||
|
||||
# Guess content type if not provided
|
||||
if not content_type:
|
||||
content_type, _encoding = mimetypes.guess_type(fname)
|
||||
|
||||
if content_type is None:
|
||||
# No guess could be made, or the file is encoded (compressed), so
|
||||
# use a generic bag-of-bits type.
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
maintype, subtype = content_type.split("/", 1)
|
||||
|
||||
if maintype == "text":
|
||||
# Note: we should handle calculating the charset
|
||||
if isinstance(fcontent, bytes):
|
||||
# If bytes are provided, assume UTF-8
|
||||
fcontent = fcontent.decode("utf-8")
|
||||
|
||||
part = MIMEText(fcontent, _subtype=subtype, _charset="utf-8")
|
||||
|
||||
elif maintype == "image":
|
||||
if isinstance(fcontent, str):
|
||||
fcontent = fcontent.encode("utf-8")
|
||||
part = MIMEText(fcontent, _subtype=subtype, _charset="utf-8")
|
||||
elif maintype == "image":
|
||||
part = MIMEImage(fcontent, _subtype=subtype)
|
||||
|
||||
elif maintype == "audio":
|
||||
if isinstance(fcontent, str):
|
||||
fcontent = fcontent.encode("utf-8")
|
||||
part = MIMEAudio(fcontent, _subtype=subtype)
|
||||
|
||||
else:
|
||||
if isinstance(fcontent, str):
|
||||
fcontent = fcontent.encode("utf-8")
|
||||
|
||||
part = MIMEBase(maintype, subtype)
|
||||
part.set_payload(fcontent)
|
||||
# Encode the payload using Base64
|
||||
from email import encoders
|
||||
|
||||
encoders.encode_base64(part)
|
||||
|
||||
# Set the filename parameter
|
||||
if fname:
|
||||
attachment_type = "inline" if inline else "attachment"
|
||||
clean_filename = re.sub("[\r\n]", "", str(fname))
|
||||
clean_filename = re.sub(r"[\r\n]", "", str(fname))
|
||||
part.add_header("Content-Disposition", attachment_type, filename=clean_filename)
|
||||
|
||||
if content_id:
|
||||
part.add_header("Content-ID", f"<{content_id}>")
|
||||
|
||||
|
|
|
|||
|
|
@ -265,6 +265,10 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
|
|||
if dt: # not called from a doctype (from a page)
|
||||
if not dn:
|
||||
dn = dt # single
|
||||
|
||||
if not isinstance(dn, str | int):
|
||||
frappe.throw("'dn' must be a string or an integer")
|
||||
|
||||
doc = frappe.get_doc(dt, dn, check_permission=True)
|
||||
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -424,6 +424,8 @@ def login():
|
|||
|
||||
@frappe.whitelist()
|
||||
def reset_password(user: str, password: str, logout: int):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
ldap: LDAPSettings = frappe.get_doc("LDAP Settings")
|
||||
if not ldap.enabled:
|
||||
frappe.throw(_("LDAP is not enabled."))
|
||||
|
|
|
|||
1981
frappe/locale/ar.po
1981
frappe/locale/ar.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/bs.po
1983
frappe/locale/bs.po
File diff suppressed because it is too large
Load diff
1979
frappe/locale/cs.po
1979
frappe/locale/cs.po
File diff suppressed because it is too large
Load diff
1979
frappe/locale/da.po
1979
frappe/locale/da.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/de.po
1983
frappe/locale/de.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/eo.po
1983
frappe/locale/eo.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/es.po
1983
frappe/locale/es.po
File diff suppressed because it is too large
Load diff
1981
frappe/locale/fa.po
1981
frappe/locale/fa.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/fr.po
1983
frappe/locale/fr.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/hr.po
1983
frappe/locale/hr.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/hu.po
1983
frappe/locale/hu.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/id.po
1983
frappe/locale/id.po
File diff suppressed because it is too large
Load diff
1981
frappe/locale/it.po
1981
frappe/locale/it.po
File diff suppressed because it is too large
Load diff
1979
frappe/locale/my.po
1979
frappe/locale/my.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/nb.po
1983
frappe/locale/nb.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/nl.po
1983
frappe/locale/nl.po
File diff suppressed because it is too large
Load diff
1979
frappe/locale/pl.po
1979
frappe/locale/pl.po
File diff suppressed because it is too large
Load diff
1981
frappe/locale/pt.po
1981
frappe/locale/pt.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1983
frappe/locale/ru.po
1983
frappe/locale/ru.po
File diff suppressed because it is too large
Load diff
1979
frappe/locale/sl.po
1979
frappe/locale/sl.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/sr.po
1983
frappe/locale/sr.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1995
frappe/locale/sv.po
1995
frappe/locale/sv.po
File diff suppressed because it is too large
Load diff
1981
frappe/locale/th.po
1981
frappe/locale/th.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/tr.po
1983
frappe/locale/tr.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/vi.po
1983
frappe/locale/vi.po
File diff suppressed because it is too large
Load diff
1983
frappe/locale/zh.po
1983
frappe/locale/zh.po
File diff suppressed because it is too large
Load diff
|
|
@ -1324,7 +1324,7 @@ class BaseDocument:
|
|||
df = self.meta.get_field(key)
|
||||
db_value = db_values.get(key)
|
||||
|
||||
if df and not df.allow_on_submit and (self.get(key) or db_value):
|
||||
if df and not df.allow_on_submit and not df.is_virtual and (self.get(key) or db_value):
|
||||
if df.fieldtype in table_fields:
|
||||
# just check if the table size has changed
|
||||
# individual fields will be checked in the loop for children
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -238,17 +239,23 @@ def remove_orphan_entities():
|
|||
all_enitities = frappe.get_all(
|
||||
app_entity, filters=entity_filter_map.get(app_entity), fields=["name", "app"]
|
||||
)
|
||||
for i, w in enumerate(all_enitities):
|
||||
if w.app and not check_if_record_exists("app", frappe.get_app_path(w.app), app_entity, w.name):
|
||||
try:
|
||||
print(f"Deleting entity {app_entity} {w.name}")
|
||||
frappe.delete_doc(app_entity, w.name, force=True, ignore_missing=True)
|
||||
update_progress_bar(f"Deleting orphaned {app_entity}", i, len(all_enitities))
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error occurred while deleting entity: {app_entity} {w.name}")
|
||||
print(e)
|
||||
for i, entity in enumerate(all_enitities):
|
||||
try:
|
||||
if entity.app:
|
||||
app_path = frappe.get_app_path(entity.app)
|
||||
if not check_if_record_exists("app", app_path, app_entity, entity.name):
|
||||
try:
|
||||
print(f"Deleting entity {app_entity} {entity.name}")
|
||||
frappe.delete_doc(app_entity, entity.name, force=True, ignore_missing=True)
|
||||
update_progress_bar(f"Deleting orphaned {app_entity}", i, len(all_enitities))
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"Error occurred while deleting entity: {app_entity} {entity.name}")
|
||||
print(e)
|
||||
except ModuleNotFoundError as e:
|
||||
print(e)
|
||||
print(f"Deleting entity {app_entity} {entity.name}")
|
||||
frappe.db.delete(app_entity, {"name": entity.name})
|
||||
|
||||
# save the deleted icons
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import base64
|
|||
import datetime
|
||||
import hashlib
|
||||
import re
|
||||
from http import cookies
|
||||
from urllib.parse import unquote, urljoin, urlparse
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from oauthlib.common import Request
|
||||
from oauthlib.openid import RequestValidator
|
||||
|
||||
import frappe
|
||||
from frappe.auth import LoginManager
|
||||
from frappe.integrations.doctype.oauth_client.oauth_client import OAuthClient
|
||||
from frappe.utils.data import cstr, get_system_timezone, now_datetime
|
||||
|
||||
|
||||
|
|
@ -73,13 +74,11 @@ class OAuthWebRequestValidator(RequestValidator):
|
|||
# Post-authorization
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
cookie_dict = get_cookie_dict_from_headers(request)
|
||||
|
||||
oac = frappe.new_doc("OAuth Authorization Code")
|
||||
oac.scopes = get_url_delimiter().join(request.scopes)
|
||||
oac.redirect_uri_bound_to_authorization_code = request.redirect_uri
|
||||
oac.client = client_id
|
||||
oac.user = unquote(cookie_dict["user_id"].value)
|
||||
oac.user = frappe.session.user
|
||||
oac.authorization_code = code["code"]
|
||||
|
||||
if request.nonce:
|
||||
|
|
@ -92,43 +91,32 @@ class OAuthWebRequestValidator(RequestValidator):
|
|||
oac.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
def authenticate_client(self, request, *args, **kwargs):
|
||||
def authenticate_client(self, request: Request, *args, **kwargs) -> bool | None:
|
||||
"""
|
||||
Loads the client based on request parameters and sets in oauth request.
|
||||
Returns True on success, None on error.
|
||||
"""
|
||||
# Get ClientID in URL
|
||||
if request.client_id:
|
||||
oc = frappe.get_doc("OAuth Client", request.client_id)
|
||||
client_name = request.client_id
|
||||
else:
|
||||
# Extract token, instantiate OAuth Bearer Token and use clientid from there.
|
||||
if "refresh_token" in frappe.form_dict:
|
||||
oc = frappe.get_doc(
|
||||
"OAuth Client",
|
||||
frappe.db.get_value(
|
||||
"OAuth Bearer Token",
|
||||
{"refresh_token": frappe.form_dict["refresh_token"]},
|
||||
"client",
|
||||
),
|
||||
)
|
||||
token_filters = {"refresh_token": frappe.form_dict["refresh_token"]}
|
||||
elif "token" in frappe.form_dict:
|
||||
oc = frappe.get_doc(
|
||||
"OAuth Client",
|
||||
frappe.db.get_value("OAuth Bearer Token", frappe.form_dict["token"], "client"),
|
||||
)
|
||||
token_filters = {"name": frappe.form_dict["token"]}
|
||||
else:
|
||||
oc = frappe.get_doc(
|
||||
"OAuth Client",
|
||||
frappe.db.get_value(
|
||||
"OAuth Bearer Token",
|
||||
frappe.get_request_header("Authorization").split(" ")[1],
|
||||
"client",
|
||||
),
|
||||
)
|
||||
token_filters = {"name": frappe.get_request_header("Authorization").split(" ")[1]}
|
||||
|
||||
client_name = frappe.db.get_value("OAuth Bearer Token", filters=token_filters, fieldname="client")
|
||||
|
||||
oc: OAuthClient = frappe.get_doc("OAuth Client", client_name)
|
||||
try:
|
||||
request.client = request.client or oc.as_dict()
|
||||
except Exception as e:
|
||||
return generate_json_error_response(e)
|
||||
|
||||
cookie_dict = get_cookie_dict_from_headers(request)
|
||||
user_id = unquote(cookie_dict.get("user_id").value) if "user_id" in cookie_dict else "Guest"
|
||||
return frappe.session.user == user_id
|
||||
return True
|
||||
|
||||
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||
cli_id = frappe.db.get_value("OAuth Client", client_id, "name")
|
||||
|
|
@ -506,13 +494,6 @@ class OAuthWebRequestValidator(RequestValidator):
|
|||
return True
|
||||
|
||||
|
||||
def get_cookie_dict_from_headers(r):
|
||||
cookie = cookies.BaseCookie()
|
||||
if r.headers.get("Cookie"):
|
||||
cookie.load(r.headers.get("Cookie"))
|
||||
return cookie
|
||||
|
||||
|
||||
def calculate_at_hash(access_token, hash_alg):
|
||||
"""Helper method for calculating an access token
|
||||
hash, as described in http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
"description": "Letter Head in HTML",
|
||||
"fieldname": "content",
|
||||
"fieldtype": "HTML Editor",
|
||||
"ignore_xss_filter": 1,
|
||||
"label": "Header HTML",
|
||||
"oldfieldname": "content",
|
||||
"oldfieldtype": "Text Editor"
|
||||
|
|
@ -113,6 +114,7 @@
|
|||
"description": "Footer will display correctly only in PDF",
|
||||
"fieldname": "footer",
|
||||
"fieldtype": "HTML Editor",
|
||||
"ignore_xss_filter": 1,
|
||||
"label": "Footer HTML"
|
||||
},
|
||||
{
|
||||
|
|
@ -184,6 +186,7 @@
|
|||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval: doc.header_script || doc.footer_script",
|
||||
"depends_on": "eval: !doc.__islocal",
|
||||
"fieldname": "scripts_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Scripts"
|
||||
|
|
@ -200,7 +203,7 @@
|
|||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"max_attachments": 3,
|
||||
"modified": "2024-04-12 10:30:25.793932",
|
||||
"modified": "2026-02-24 20:53:14.297567",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Printing",
|
||||
"name": "Letter Head",
|
||||
|
|
@ -223,8 +226,9 @@
|
|||
"role": "Desk User"
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -706,7 +706,10 @@ frappe.ui.form.PrintView = class {
|
|||
return;
|
||||
}
|
||||
} else {
|
||||
this.is_wkhtmltopdf_valid();
|
||||
let pdf_generator = this.get_pdf_generator(print_format?.pdf_generator);
|
||||
if (pdf_generator === "wkhtmltopdf") {
|
||||
this.is_wkhtmltopdf_valid();
|
||||
}
|
||||
this.render_page(
|
||||
"/api/method/frappe.utils.print_format.download_pdf?",
|
||||
false,
|
||||
|
|
@ -738,9 +741,7 @@ frappe.ui.form.PrintView = class {
|
|||
encodeURIComponent(this.get_letterhead()) +
|
||||
"&settings=" +
|
||||
encodeURIComponent(JSON.stringify(this.additional_settings)) +
|
||||
(this.lang_code ? "&_lang=" + this.lang_code : "") +
|
||||
"&pdf_generator=" +
|
||||
encodeURIComponent(pdf_generator)
|
||||
(this.lang_code ? "&_lang=" + this.lang_code : "")
|
||||
)
|
||||
);
|
||||
if (!w) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ $(document).ready(function () {
|
|||
dismiss_key: `${frappe.boot.site_info.name}_trial_card_time`,
|
||||
dismiss_it_for: "day",
|
||||
};
|
||||
let visiblity_condition =
|
||||
frappe.boot.is_fc_site &&
|
||||
!!frappe.boot.setup_complete &&
|
||||
!frappe.is_mobile() &&
|
||||
frappe.user.has_role("System Manager");
|
||||
if (visiblity_condition && isFCUser) {
|
||||
addChatBubble();
|
||||
}
|
||||
if (isFCUser) {
|
||||
$.extend(card_args, {
|
||||
primary_action_label: "Upgrade",
|
||||
|
|
@ -42,12 +50,7 @@ $(document).ready(function () {
|
|||
});
|
||||
}
|
||||
$(document).on("desktop_screen", function (event, data) {
|
||||
if (
|
||||
frappe.boot.is_fc_site &&
|
||||
!!frappe.boot.setup_complete &&
|
||||
!frappe.is_mobile() &&
|
||||
frappe.user.has_role("System Manager")
|
||||
) {
|
||||
if (visiblity_condition) {
|
||||
if (site_info.trial_end_date && trial_end_date > new Date()) {
|
||||
card_args.parent = $(".icons-container").first();
|
||||
let banner_card = new frappe.ui.SidebarCard(card_args);
|
||||
|
|
@ -84,3 +87,21 @@ function openFrappeCloudDashboard() {
|
|||
"_blank"
|
||||
);
|
||||
}
|
||||
|
||||
function addChatBubble() {
|
||||
if (checkBusinessHours()) {
|
||||
let chat_banner = document.createElement("script");
|
||||
chat_banner.innerHTML =
|
||||
'(function(d,t){var BASE_URL="https://chat.frappe.cloud";var g=d.createElement(t),s=d.getElementsByTagName(t)[0];g.src=BASE_URL+"/packs/js/sdk.js";g.async=true;s.parentNode.insertBefore(g,s);g.onload=function(){window.chatwootSDK.run({websiteToken:"LdmfJzftdJGEcFjoTqk8CrSq",baseUrl:BASE_URL})}})(document,"script");';
|
||||
document.body.append(chat_banner);
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--s-700", "var(--gray-50)");
|
||||
}
|
||||
}
|
||||
|
||||
function checkBusinessHours() {
|
||||
let currentTime = new Date();
|
||||
const istTime = new Date(currentTime.toLocaleString("en-US", { timeZone: "Asia/Kolkata" }));
|
||||
|
||||
return istTime.getHours() >= 11 && istTime.getHours() <= 18;
|
||||
}
|
||||
|
|
|
|||
2
frappe/public/js/bootstrap-4-web.bundle.js
vendored
2
frappe/public/js/bootstrap-4-web.bundle.js
vendored
|
|
@ -25,7 +25,7 @@ frappe.get_modal = function (title, content) {
|
|||
<div class="modal-header">
|
||||
<h5 class="modal-title">${title}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
${frappe.utils.icon("close-alt", "sm", "close-alt")}
|
||||
${frappe.utils.icon("x", "sm", "close-alt")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
|
|
|||
|
|
@ -113,3 +113,4 @@ import "./frappe/scanner";
|
|||
|
||||
import "./frappe/ui/address_autocomplete/autocomplete_dialog.js";
|
||||
import "./frappe/ui/desktop_icon.html";
|
||||
import "./frappe/ui/user_onboarding/user_onboarding.bundle.js";
|
||||
|
|
|
|||
|
|
@ -324,8 +324,8 @@ frappe.get_data_pill = (
|
|||
`);
|
||||
if (remove_action) {
|
||||
let remove_btn = $(`
|
||||
<span class="remove-btn cursor-pointer">
|
||||
${frappe.utils.icon("close", "sm", "es-icon")}
|
||||
<span class="remove-btn cursor-pointer flex align-items-center">
|
||||
${frappe.utils.icon("x", "sm")}
|
||||
</span>
|
||||
`);
|
||||
if (typeof remove_action === "function") {
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
|
|||
.attr("href", dataurl || this.value);
|
||||
}
|
||||
} else {
|
||||
this.$input.toggle(true);
|
||||
this.$value.toggle(false);
|
||||
this.$input?.toggle(true);
|
||||
this.$value?.toggle(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ frappe.ui.form.ControlButton = class ControlButton extends frappe.ui.form.Contro
|
|||
this.$input = $(
|
||||
`<button
|
||||
class="btn ${frappe.utils.escape_html(btn_size)} ${frappe.utils.escape_html(btn_type)}"
|
||||
title="${frappe.utils.escape_html(this.df.label)}"
|
||||
title="${this.df.title || frappe.utils.escape_html(this.df.label)}"
|
||||
>`
|
||||
)
|
||||
.prependTo(me.input_area)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ frappe.ui.form.ControlFloat = class ControlFloat extends frappe.ui.form.ControlI
|
|||
}
|
||||
|
||||
get_number_format() {
|
||||
if (this.df.fieldtype === "Float" && !this.df.options?.trim()) return;
|
||||
if (
|
||||
this.df.fieldtype === "Rating" ||
|
||||
(this.df.fieldtype === "Float" && !this.df.options?.trim())
|
||||
)
|
||||
return;
|
||||
|
||||
const currency = frappe.meta.get_field_currency(this.df, this.get_doc());
|
||||
return get_number_format(currency);
|
||||
|
|
|
|||
|
|
@ -783,6 +783,7 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
this.show_submit_message();
|
||||
this.clear_custom_buttons();
|
||||
this.show_web_link();
|
||||
this.show_report_bug_link();
|
||||
this.show_workflow_read_only_banner();
|
||||
}
|
||||
|
||||
|
|
@ -1280,6 +1281,17 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
}
|
||||
}
|
||||
|
||||
show_report_bug_link() {
|
||||
if (this.meta.beta) {
|
||||
this.add_web_link(
|
||||
"https://github.com/frappe/" +
|
||||
frappe.boot.module_app[frappe.scrub(this.meta.module)] +
|
||||
"/issues/new",
|
||||
__("Report bug")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
add_web_link(path, label) {
|
||||
label = __(label) || __("See on Website");
|
||||
this.web_link = this.sidebar
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ frappe.ui.form.Layout = class Layout {
|
|||
}
|
||||
|
||||
// Add close button to block if not permanent
|
||||
const close_message = $(`<div class="close-message">${frappe.utils.icon("close")}</div>`);
|
||||
const close_message = $(`<div class="close-message">${frappe.utils.icon("x")}</div>`);
|
||||
if (!permanent) {
|
||||
close_message.appendTo($html);
|
||||
close_message.on("click", () => $html.remove());
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -285,8 +285,8 @@ frappe.ui.form.AssignmentClass = class AssignmentClass {
|
|||
const row = $(`
|
||||
<div class="dialog-assignment-row" data-user="${assignment}">
|
||||
<div class="assignee">
|
||||
${frappe.avatar(assignment)}
|
||||
${frappe.user.full_name(assignment)}
|
||||
${frappe.avatar(assignment, "avatar-smaller")}
|
||||
<span>${frappe.user.full_name(assignment)}</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Actions">
|
||||
</div>
|
||||
|
|
@ -297,8 +297,8 @@ frappe.ui.form.AssignmentClass = class AssignmentClass {
|
|||
|
||||
if (assignment === frappe.session.user) {
|
||||
btn_group.append(`
|
||||
<button type="button" class="btn complete-btn" title="${__("Done")}">
|
||||
${frappe.utils.icon("tick", "xs")}
|
||||
<button type="button" class="btn btn-xs complete-btn" title="${__("Done")}">
|
||||
${frappe.utils.icon("check")}
|
||||
</button>
|
||||
`);
|
||||
btn_group.find(".complete-btn").click(() => {
|
||||
|
|
@ -324,7 +324,7 @@ frappe.ui.form.AssignmentClass = class AssignmentClass {
|
|||
|
||||
if (assignment === frappe.session.user || this.frm.perm[0].write) {
|
||||
btn_group.append(`
|
||||
<button type="button" class="btn remove-btn" title="${__("Cancel")}">
|
||||
<button type="button" class="btn btn-xs remove-btn" title="${__("Cancel")}">
|
||||
${frappe.utils.icon("x")}
|
||||
</button>
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -115,7 +116,6 @@ frappe.ui.form.Sidebar = class {
|
|||
make_like() {
|
||||
this.like_wrapper = this.sidebar.find(".liked-by");
|
||||
this.like_icon = this.sidebar.find(".liked-by .like-icon");
|
||||
this.like_count = this.sidebar.find(".liked-by .like-count");
|
||||
frappe.ui.setup_like_popover(this.sidebar.find(".form-stats-likes"), ".like-icon");
|
||||
|
||||
this.like_icon.on("click", () => {
|
||||
|
|
@ -138,8 +138,6 @@ frappe.ui.form.Sidebar = class {
|
|||
.toggleClass("liked", liked)
|
||||
.attr("data-doctype", this.frm.doctype)
|
||||
.attr("data-name", this.frm.doc.name);
|
||||
|
||||
this.like_count && this.like_count.text(JSON.parse(this.frm.doc._liked_by || "[]").length);
|
||||
}
|
||||
|
||||
refresh_web_view_count() {
|
||||
|
|
@ -245,19 +243,23 @@ frappe.ui.form.Sidebar = class {
|
|||
}
|
||||
|
||||
add_user_action(label, click) {
|
||||
return $("<a>")
|
||||
.html(label)
|
||||
.appendTo(
|
||||
$('<div class="user-action-row"></div>').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 = $('<div class="user-action-row"></div>').appendTo(parent);
|
||||
|
||||
return $('<a class="user-action-link"></a>')
|
||||
.html(
|
||||
`<span class="user-action-label">${label}</span>
|
||||
<span class="user-action-external-icon">${frappe.utils.icon("external-link", "sm")}</span>`
|
||||
)
|
||||
.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() {}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
<div class="sidebar-section user-actions hidden"></div>
|
||||
<div class="flex justify-between sidebar-image-section sidebar-section hide">
|
||||
<div class="sidebar-image-wrapper">
|
||||
<img class="sidebar-image">
|
||||
|
|
@ -25,7 +24,6 @@
|
|||
<svg class="icon icon-sm like-icon pointer">
|
||||
<use href="#icon-heart"></use>
|
||||
</svg>
|
||||
<span class="like-count ml-2"></span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -34,14 +32,19 @@
|
|||
<div class="flex justify-between overflow-hidden">
|
||||
<div class="ellipsis">
|
||||
{% let title = frm.get_title(); %}
|
||||
{% if (title && title !== frm.doc.name) { %}
|
||||
<div class="form-details flex justify-between">
|
||||
<span class="bold ellipsis form-title-text mr-3 text-medium">{%= frappe.utils.escape_html(frappe.utils.html2text(title)) %}</span>
|
||||
</div>
|
||||
{% } %}
|
||||
<div class="form-name-container mt-2 flex justify-between form-name-copy" data-copy="{{frm.doc.name}}" title="{{frm.doc.name}}" >
|
||||
{% if frm.meta.beta %}
|
||||
<div class="pt-1">
|
||||
<label class="indicator-pill yellow mb-0" title="{{ __("This feature is brand new and still experimental") }}">{{ __("Experimental") }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if (title && title !== frm.doc.name) { %}
|
||||
<div class="form-name-container mt-1 flex justify-between form-name-copy" data-copy="{{frm.doc.name}}" title="{{frm.doc.name}}" >
|
||||
<span class="ellipsis mr-3">{%= frm.doc.name %}</span>
|
||||
</div>
|
||||
{% } %}
|
||||
</div>
|
||||
{% if not image_field %}
|
||||
<div class="align-items-baseline flex form-stats-likes">
|
||||
|
|
@ -51,21 +54,10 @@
|
|||
<svg class="icon icon-sm like-icon pointer">
|
||||
<use href="#icon-heart"></use>
|
||||
</svg>
|
||||
<span class="like-count ml-2"></span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if frm.meta.beta %}
|
||||
<div class="flex pt-2 justify-between">
|
||||
<div>
|
||||
<a class="small text-muted" href="https://github.com/frappe/{{ frappe.boot.module_app[frappe.scrub(frm.meta.module)] }}/issues/new"
|
||||
target="_blank">
|
||||
{{ __("Report bug") }}</a>
|
||||
</div>
|
||||
<label class="indicator-pill yellow mb-0" title="{{ __("This feature is brand new and still experimental") }}">{{ __("Experimental") }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="sidebar-section sidebar-rating hide border-bottom">
|
||||
<div style="position: relative;">
|
||||
|
|
@ -82,6 +74,15 @@
|
|||
</div>
|
||||
{% } %}
|
||||
</div>
|
||||
<div class="sidebar-section user-actions hidden border-bottom">
|
||||
<div class="form-sidebar-items user-actions-header">
|
||||
<div class="form-sidebar-label">
|
||||
{%= frappe.utils.icon("link-2") %}
|
||||
<span class="ellipsis">{%= __("Links") %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions-list"></div>
|
||||
</div>
|
||||
<div class="sidebar-section form-assignments">
|
||||
<div>
|
||||
<span class="form-sidebar-items">
|
||||
|
|
|
|||
|
|
@ -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 add_button_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"),
|
||||
add_button_label,
|
||||
() => {
|
||||
if (this.settings.primary_action) {
|
||||
this.settings.primary_action();
|
||||
|
|
@ -304,12 +305,26 @@ 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, add_button_label);
|
||||
}
|
||||
} else {
|
||||
this.page.clear_primary_action();
|
||||
}
|
||||
}
|
||||
|
||||
_trim_primary_action_if_overflow(btn, add_button_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", add_button_label).tooltip();
|
||||
btn.find("span").text(short_label);
|
||||
}
|
||||
}
|
||||
|
||||
make_new_doc() {
|
||||
const doctype = this.doctype;
|
||||
const options = {};
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ $.extend(frappe.model, {
|
|||
allowed_records.length;
|
||||
|
||||
// don't set defaults for "User" link field using User Permissions!
|
||||
if (df.fieldtype === "Link" && df.options !== "User") {
|
||||
if (!df.read_only && df.fieldtype === "Link" && df.options !== "User") {
|
||||
// If user permission has Is Default enabled or single-user permission has found against respective doctype.
|
||||
if (has_user_permissions && default_doc) {
|
||||
value = default_doc;
|
||||
|
|
|
|||
|
|
@ -145,7 +145,14 @@ frappe.request.call = function (opts) {
|
|||
opts.error_callback && opts.error_callback();
|
||||
},
|
||||
403: function (xhr) {
|
||||
if (frappe.session.user === "Guest" && frappe.session.logged_in_user !== "Guest") {
|
||||
const user_id = document.cookie
|
||||
.split(";")
|
||||
.find((c) => c.trim().startsWith("user_id="))
|
||||
?.split("=")[1];
|
||||
if (
|
||||
user_id === "Guest" ||
|
||||
(frappe.session.user === "Guest" && frappe.session.logged_in_user !== "Guest")
|
||||
) {
|
||||
// session expired
|
||||
frappe.app.handle_session_expired();
|
||||
} else if (xhr.responseJSON && xhr.responseJSON._error_message) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
{% if (!in_folder) { %}
|
||||
<div class="icon-caption">
|
||||
<div class="icon-title" data-toggle="tooltip" data-original-title="{{ __(icon.label) }}">{{ __(icon.label) }}</div>
|
||||
<div class="icon-subtitle"></div>
|
||||
<!-- <div class="icon-subtitle"></div> -->
|
||||
</div>
|
||||
{% } %}
|
||||
{% if(!icon.in_folder && !icon.restrict_removal) { %}
|
||||
|
|
@ -25,4 +25,4 @@
|
|||
{%= frappe.utils.icon("x") %}
|
||||
</div>
|
||||
{% } %}
|
||||
</a>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// MIT License. See license.txt
|
||||
import { createPopper } from "@popperjs/core";
|
||||
|
||||
frappe.ui.is_liked = function (doc) {
|
||||
return frappe.ui.get_liked_by(doc).includes(frappe.session.user);
|
||||
|
|
@ -65,63 +66,157 @@ frappe.ui.setup_like_popover = ($parent, selector) => {
|
|||
return;
|
||||
}
|
||||
|
||||
$parent.on("mouseover", selector, function () {
|
||||
const target_element = $(this);
|
||||
target_element.popover({
|
||||
animation: true,
|
||||
placement: "bottom",
|
||||
trigger: "manual",
|
||||
template: `<div class="liked-by-popover popover">
|
||||
<div class="arrow"></div>
|
||||
let active_target = null;
|
||||
let active_popover = null;
|
||||
let active_popper = null;
|
||||
let hide_timer = null;
|
||||
|
||||
const clear_hide_timer = () => {
|
||||
if (hide_timer) {
|
||||
clearTimeout(hide_timer);
|
||||
hide_timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const destroy_active_popover = () => {
|
||||
clear_hide_timer();
|
||||
if (active_target) {
|
||||
active_target.off(".likePopover");
|
||||
}
|
||||
if (active_popover) {
|
||||
active_popover.off(".likePopover");
|
||||
active_popover.remove();
|
||||
}
|
||||
if (active_popper) {
|
||||
active_popper.destroy();
|
||||
}
|
||||
active_target = null;
|
||||
active_popover = null;
|
||||
active_popper = null;
|
||||
};
|
||||
|
||||
const schedule_hide = () => {
|
||||
clear_hide_timer();
|
||||
hide_timer = setTimeout(() => {
|
||||
destroy_active_popover();
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const get_liked_by_users = (target_element) => {
|
||||
let liked_by = target_element.parents(".liked-by").attr("data-liked-by");
|
||||
liked_by = liked_by ? decodeURI(liked_by) : "[]";
|
||||
return JSON.parse(liked_by);
|
||||
};
|
||||
|
||||
const get_popover_content = (target_element) => {
|
||||
const liked_by = get_liked_by_users(target_element);
|
||||
const content = $('<div class="liked-by-popover-content"></div>');
|
||||
const like_count = liked_by.length;
|
||||
|
||||
if (like_count > 3) {
|
||||
const like_summary = __("Liked by {0} people", [like_count]);
|
||||
const like_count_html = $(
|
||||
`<div class="liked-by-popover-summary">${like_summary}</div>`
|
||||
);
|
||||
content.append(like_count_html);
|
||||
}
|
||||
|
||||
if (!liked_by.length) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const liked_by_list = $('<ul class="list-unstyled"></ul>');
|
||||
const link_base = "/desk/user/";
|
||||
|
||||
liked_by.forEach((user) => {
|
||||
liked_by_list.append(`
|
||||
<li data-user=${user}>${frappe.avatar(user, "avatar-xs")}
|
||||
<span>${frappe.user.full_name(user)}</span>
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
|
||||
liked_by_list.children("li").on("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const user = ev.currentTarget.dataset.user;
|
||||
setTimeout(() => destroy_active_popover(), 0);
|
||||
frappe.set_route(link_base + user);
|
||||
});
|
||||
|
||||
content.append(liked_by_list);
|
||||
return content;
|
||||
};
|
||||
|
||||
const show_popover = (target_element) => {
|
||||
if (!get_liked_by_users(target_element).length) {
|
||||
destroy_active_popover();
|
||||
return;
|
||||
}
|
||||
|
||||
if (active_target?.get(0) === target_element.get(0) && active_popover) {
|
||||
clear_hide_timer();
|
||||
active_popper?.update();
|
||||
return;
|
||||
}
|
||||
|
||||
destroy_active_popover();
|
||||
|
||||
const popover = $(
|
||||
`<div class="liked-by-popover popover show" role="tooltip">
|
||||
<div class="popover-body popover-content"></div>
|
||||
</div>`,
|
||||
content: () => {
|
||||
let liked_by = target_element.parents(".liked-by").attr("data-liked-by");
|
||||
liked_by = liked_by ? decodeURI(liked_by) : "[]";
|
||||
liked_by = JSON.parse(liked_by);
|
||||
</div>`
|
||||
);
|
||||
|
||||
if (!liked_by.length) {
|
||||
return "";
|
||||
}
|
||||
popover.find(".popover-content").append(get_popover_content(target_element));
|
||||
$(document.body).append(popover);
|
||||
|
||||
let liked_by_list = $(`<ul class="list-unstyled"></ul>`);
|
||||
|
||||
// to show social profile of the user
|
||||
let link_base = "/desk/user/";
|
||||
|
||||
liked_by.forEach((user) => {
|
||||
// append user list item
|
||||
liked_by_list.append(`
|
||||
<li data-user=${user}>${frappe.avatar(user, "avatar-xs")}
|
||||
<span>${frappe.user.full_name(user)}</span>
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
|
||||
liked_by_list.children("li").click((ev) => {
|
||||
let user = ev.currentTarget.dataset.user;
|
||||
target_element.popover("hide");
|
||||
frappe.set_route(link_base + user);
|
||||
});
|
||||
|
||||
return liked_by_list;
|
||||
},
|
||||
html: true,
|
||||
container: "body",
|
||||
const popper = createPopper(target_element.get(0), popover.get(0), {
|
||||
placement: "bottom",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
padding: 12,
|
||||
fallbackPlacements: ["bottom-start", "top", "top-start"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
target_element.popover("show");
|
||||
active_target = target_element;
|
||||
active_popover = popover;
|
||||
active_popper = popper;
|
||||
|
||||
$(".popover").on("mouseleave", () => {
|
||||
target_element.popover("hide");
|
||||
});
|
||||
target_element
|
||||
.off(".likePopover")
|
||||
.on("mouseenter.likePopover", clear_hide_timer)
|
||||
.on("mouseleave.likePopover", schedule_hide);
|
||||
|
||||
target_element.on("mouseout", () => {
|
||||
setTimeout(() => {
|
||||
if (!$(".popover:hover").length) {
|
||||
target_element.popover("hide");
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
popover
|
||||
.off(".likePopover")
|
||||
.on("mousedown.likePopover click.likePopover", (ev) => {
|
||||
ev.stopPropagation();
|
||||
})
|
||||
.on("mouseenter.likePopover", clear_hide_timer)
|
||||
.on("mouseleave.likePopover", schedule_hide);
|
||||
};
|
||||
|
||||
$parent.on("mouseenter", selector, function () {
|
||||
show_popover($(this));
|
||||
});
|
||||
|
||||
$parent.on("mouseleave", selector, schedule_hide);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ frappe.show_alert = frappe.toast = function (message, seconds = 7, actions = {})
|
|||
<div class="alert-subtitle">${message.subtitle || ""}</div>
|
||||
</div>
|
||||
<div class="alert-body" style="display: none"></div>
|
||||
<a class="close">${frappe.utils.icon("close-alt")}</a>
|
||||
<a class="close">${frappe.utils.icon("x")}</a>
|
||||
</div>
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@
|
|||
Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="promotional-banners"></div>
|
||||
<p>
|
||||
<a class="onboarding-sidebar">
|
||||
{%= frappe.utils.icon("user-check" , "sm", "", "", "text-ink-gray-7 current-color", true)%}
|
||||
<span> {%= __("Getting Started") %} </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 +91,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="overlay" style="z-index: 1021;"></div>
|
||||
<div class="user-onboarding"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,145 @@ frappe.ui.Sidebar = class Sidebar {
|
|||
}
|
||||
}
|
||||
|
||||
setup_promotional_banners() {
|
||||
if (
|
||||
cint(frappe.sys_defaults?.disable_product_suggestion) ||
|
||||
!frappe.user.has_role("System Manager")
|
||||
)
|
||||
return;
|
||||
|
||||
let module = this.all_sidebar_items?.[this.workspace_title]?.["module"] || "";
|
||||
if (!module) return;
|
||||
|
||||
this.$promotional_banners = this.wrapper.find(".promotional-banners");
|
||||
this.$promotional_banners.empty();
|
||||
this.promotional_banners = [];
|
||||
|
||||
this.get_crm_banner(module);
|
||||
this.get_helpdesk_banner(module);
|
||||
|
||||
this.render_promotional_banners();
|
||||
}
|
||||
|
||||
get_crm_banner(module) {
|
||||
if (module != "CRM") return;
|
||||
|
||||
const icon =
|
||||
$(`<svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 11.2C0 7.27963 0 5.31945 0.762954 3.82207C1.43407 2.50493 2.50493 1.43407 3.82207 0.762954C5.31945 0 7.27963 0 11.2 0H16.8C20.7204 0 22.6806 0 24.1779 0.762954C25.4951 1.43407 26.5659 2.50493 27.237 3.82207C28 5.31945 28 7.27963 28 11.2V16.8C28 20.7204 28 22.6806 27.237 24.1779C26.5659 25.4951 25.4951 26.5659 24.1779 27.237C22.6806 28 20.7204 28 16.8 28H11.2C7.27963 28 5.31945 28 3.82207 27.237C2.50493 26.5659 1.43407 25.4951 0.762954 24.1779C0 22.6806 0 20.7204 0 16.8V11.2Z" fill="#DB4EE0"/>
|
||||
<path d="M5.02441 6.58252V9.09486H20.4627V10.9791L15.0135 16.3806V19.3201H12.9676V16.3806C12.9676 16.3806 9.78529 13.1774 8.62962 12.0469H5.03698L10.0156 17.0087C10.3045 17.2851 10.4678 17.6745 10.4678 18.0765V21.041L17.5259 21.0661V18.0765C17.5259 17.6745 17.6892 17.2851 17.9781 17.0087L22.9751 12.0343V6.58252H5.02441Z" fill="#F1FCFF"/>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
const title = __("Switch to Frappe CRM");
|
||||
const message = __(
|
||||
"Sales without complexity, lock-in and per-user costs. Try it for free!"
|
||||
);
|
||||
const link =
|
||||
"https://frappe.io/crm?utm_source=crm-sidebar&utm_medium=sidebar&utm_campaign=frappe-ad";
|
||||
|
||||
this.promotional_banners.push({ title, message, link, icon });
|
||||
}
|
||||
|
||||
get_helpdesk_banner(module) {
|
||||
if (module != "Support") return;
|
||||
|
||||
const icon =
|
||||
$(`<svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 11.2C0 7.27963 0 5.31945 0.762954 3.82207C1.43407 2.50493 2.50493 1.43407 3.82207 0.762954C5.31945 0 7.27963 0 11.2 0H16.8C20.7204 0 22.6806 0 24.1779 0.762954C25.4951 1.43407 26.5659 2.50493 27.237 3.82207C28 5.31945 28 7.27963 28 11.2V16.8C28 20.7204 28 22.6806 27.237 24.1779C26.5659 25.4951 25.4951 26.5659 24.1779 27.237C22.6806 28 20.7204 28 16.8 28H11.2C7.27963 28 5.31945 28 3.82207 27.237C2.50493 26.5659 1.43407 25.4951 0.762954 24.1779C0 22.6806 0 20.7204 0 16.8V11.2Z" fill="#7D42FB"/>
|
||||
<path d="M22.7237 12.1723V6.65771H5.26367V9.17005H20.2239V11.5568C19.2189 11.8457 18.4904 12.7753 18.4904 13.8681C18.4904 14.961 19.2189 15.878 20.2239 16.1669V18.5536H7.77601V11.9964H5.26367V21.066H22.7362V15.5514L21.2414 14.4836V13.2526L22.7362 12.1849L22.7237 12.1723Z" fill="#EDF7FF"/>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
const title = __("Switch to Helpdesk");
|
||||
const message = __(
|
||||
"Support without complexity, lock-in and per-user costs. Try it for free!"
|
||||
);
|
||||
const link =
|
||||
"https://frappe.io/helpdesk?utm_source=support-sidebar&utm_medium=sidebar&utm_campaign=frappe-ad";
|
||||
|
||||
this.promotional_banners.push({ title, message, link, icon });
|
||||
}
|
||||
|
||||
render_promotional_banners() {
|
||||
let me = this;
|
||||
|
||||
if (this.promotional_banners.length === 0) {
|
||||
this.$promotional_banners.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.$promotional_banners.show();
|
||||
|
||||
this.promotional_banners.forEach((banner) => {
|
||||
let banner_html = $(`
|
||||
<a href="${banner.link}" class="promotional-banner" target="_blank" title="${banner.message}">
|
||||
<span>${banner.title}</span>
|
||||
</a>
|
||||
`);
|
||||
|
||||
banner_html.prepend(banner.icon);
|
||||
me.$promotional_banners.append(banner_html);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return frappe
|
||||
.call({
|
||||
method: "frappe.desk.desktop.get_onboarding_data",
|
||||
args: {
|
||||
// send sorted min requirements to increase chance of cache hit
|
||||
module: module_name,
|
||||
},
|
||||
type: "GET",
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.message?.length > 0) {
|
||||
let onboarding_data = data.message[0];
|
||||
me.onboarding_widget = {};
|
||||
me.onboarding_widget[module_name] = 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;
|
||||
|
|
@ -99,6 +238,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);
|
||||
|
|
@ -109,6 +252,16 @@ frappe.ui.Sidebar = class Sidebar {
|
|||
this.sidebar_header = new frappe.ui.SidebarHeader(this);
|
||||
this.make_sidebar();
|
||||
this.add_sidebar_cards();
|
||||
this.setup_promotional_banners();
|
||||
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();
|
||||
});
|
||||
}
|
||||
add_card(card) {
|
||||
if (this.cards && this.cards.find((i) => i.title === card.title)) return;
|
||||
|
|
|
|||
|
|
@ -565,8 +565,8 @@ export class SidebarEditor {
|
|||
case "delete":
|
||||
this.delete_item(item_data);
|
||||
break;
|
||||
case "add_item_below":
|
||||
this.edit_item(item_data);
|
||||
case "add_below":
|
||||
this.add_below(item_data);
|
||||
break;
|
||||
case "duplicate":
|
||||
this.duplicate_item(item_data);
|
||||
|
|
|
|||
353
frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
Normal file
353
frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
<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),
|
||||
});
|
||||
|
||||
let skippAll = false;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
"Show Form Tour": showFormTour,
|
||||
"Update Settings": updateSettings,
|
||||
"View Report": openReport,
|
||||
"Go to Page": goToPage,
|
||||
"View Docs": viewDocs,
|
||||
};
|
||||
|
||||
if (step.action && actions[step.action]) {
|
||||
actions[step.action](step);
|
||||
} else if (step.route) {
|
||||
frappe.set_route(step.route);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
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);
|
||||
markComplete(step);
|
||||
}
|
||||
|
||||
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="text-base font-medium">Getting started</div>
|
||||
<div class="onb-header-actions">
|
||||
<button @click="toggleCollapse" v-html="minimizeIcon"></button>
|
||||
<button @click="close" v-html="closeIcon"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!collapsed" class="body">
|
||||
<div class="onb-title">
|
||||
<div class="onb-title-icon" v-html="headerIcon"></div>
|
||||
|
||||
<div class="text-base font-medium">{{ title }}</div>
|
||||
|
||||
<div class="onb-title-steps">
|
||||
{{ completedCount }}/{{ steps.length }} steps completed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="onb-progress-row">
|
||||
<div v-if="progress !== 100">
|
||||
<div class="onb-progress-badge">{{ progress }}% {{ __("completed") }}</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="onb-progress-badge-complete">
|
||||
{{ progress }}% {{ __("completed") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="skippAll">
|
||||
<span class="onb-skip" @click="resetAll(steps)"> {{ __("Reset All") }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="onb-skip" @click="skipAll(steps)">{{ __("Skip All") }}</span>
|
||||
</div>
|
||||
</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" @click="handleAction(step)">
|
||||
<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">
|
||||
{{ 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,265 @@
|
|||
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();
|
||||
this.hide_panel = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
addStyles();
|
||||
|
||||
let title = this.title || __("Welcome to Frappe!");
|
||||
let onboarding_checklist = this.steps || [];
|
||||
let header_icon = this.header_icon;
|
||||
let me = this;
|
||||
|
||||
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;
|
||||
me.hide_panel = !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-actions button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.onb-step-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.onb-step-icon {
|
||||
margin-bottom: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 14px;
|
||||
line-spacing: 1.15;
|
||||
letter-spacing: 0.02em;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.onb-step-text {
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.onb-skip {
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.onb-skip:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.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-title {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.onb-title-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.onb-title-steps {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.onb-progress-badge {
|
||||
background: #FDFAED;
|
||||
color: #DB7706;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.onb-progress-badge-complete {
|
||||
background: #E4FAEB;
|
||||
color: #278F5E;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.onb-progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
|
||||
.onb-progress-text {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .onb-panel {
|
||||
background-color: #232323;
|
||||
color: #e5e7eb;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-base {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .onb-skip {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .onb-skip:hover {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .onb-title-steps,
|
||||
[data-theme="dark"] .onb-progress-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .onb-group:hover {
|
||||
background: #374151;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .onb-progress-badge {
|
||||
background: rgba(245,158,11,0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .onb-progress-badge-complete {
|
||||
background: rgba(16,185,129,0.15);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
frappe.provide("frappe.ui");
|
||||
frappe.ui.UserOnboarding = UserOnboarding;
|
||||
export default UserOnboarding;
|
||||
|
|
@ -1575,7 +1575,8 @@ Object.assign(frappe.utils, {
|
|||
if (item.is_query_report) {
|
||||
route = "query-report/" + item.name;
|
||||
} else if (!item.is_query_report && item.report_ref_doctype) {
|
||||
route = frappe.router.slug(item.report_ref_doctype) + "/view/report/";
|
||||
route =
|
||||
frappe.router.slug(item.report_ref_doctype) + "/view/report/" + item.name;
|
||||
} else {
|
||||
route = "report/" + item.name;
|
||||
}
|
||||
|
|
@ -1909,7 +1910,13 @@ Object.assign(frappe.utils, {
|
|||
|
||||
process_filter_expression(filter) {
|
||||
let filters = [];
|
||||
filters = filter ? new Function(`return ${filter}`)() : [];
|
||||
if (filter) {
|
||||
try {
|
||||
filters = JSON.parse(filter);
|
||||
} catch {
|
||||
console.warn("Invalid JSON in filter expression", filter);
|
||||
}
|
||||
}
|
||||
return this.cleanup_filters(filters);
|
||||
},
|
||||
cleanup_filters(filters) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import localforage from "localforage";
|
|||
|
||||
frappe.last_edited_communication = {};
|
||||
const separator_element = "<div>---</div>";
|
||||
// Quill uses <p>---</p>; match both when stripping quoted content
|
||||
const separator_regex = /<(?:div|p)(?:\s[^>]*)?>---<\/(?:div|p)>/i;
|
||||
|
||||
frappe.views.CommunicationComposer = class {
|
||||
constructor(opts) {
|
||||
|
|
@ -60,6 +62,7 @@ frappe.views.CommunicationComposer = class {
|
|||
{
|
||||
fieldtype: "Button",
|
||||
label: frappe.utils.icon("down", "xs"),
|
||||
title: __("More Options"),
|
||||
fieldname: "option_toggle_button",
|
||||
click: () => {
|
||||
this.toggle_more_options();
|
||||
|
|
@ -494,7 +497,11 @@ frappe.views.CommunicationComposer = class {
|
|||
},
|
||||
];
|
||||
|
||||
frappe.utils.add_select_group_button(clear_and_add_template, email_template_actions);
|
||||
frappe.utils.add_select_group_button(
|
||||
clear_and_add_template,
|
||||
email_template_actions,
|
||||
"btn-default"
|
||||
);
|
||||
$(fields.use_html.wrapper).addClass("mt-2 text-center").appendTo(clear_and_add_template);
|
||||
}
|
||||
|
||||
|
|
@ -566,6 +573,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 <p>---</p>)
|
||||
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 +808,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 +969,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);
|
||||
|
|
|
|||
|
|
@ -243,10 +243,6 @@ body.modal-open[style^="padding-right"] {
|
|||
}
|
||||
.frappe-control:last-child {
|
||||
margin-left: 10px;
|
||||
button {
|
||||
// same as form-control input
|
||||
height: calc(1.5em + 0.7rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -268,7 +264,19 @@ body.modal-open[style^="padding-right"] {
|
|||
}
|
||||
|
||||
.frappe-control:last-child {
|
||||
margin-top: -14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal .frappe-control[data-fieldname="option_toggle_button"] {
|
||||
margin-top: 10px;
|
||||
.form-group {
|
||||
margin-bottom: 0;
|
||||
|
||||
button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -299,6 +307,9 @@ body.modal-open[style^="padding-right"] {
|
|||
}
|
||||
.assignee {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
&:hover {
|
||||
.btn-group {
|
||||
|
|
@ -306,9 +317,6 @@ body.modal-open[style^="padding-right"] {
|
|||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
}
|
||||
.avatar {
|
||||
margin-right: var(--margin-md);
|
||||
}
|
||||
}
|
||||
|
||||
// Stack minimized modals
|
||||
|
|
|
|||
|
|
@ -98,6 +98,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.avatar-smaller {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
text-align: center;
|
||||
|
||||
.standard-image {
|
||||
@include get_textstyle("xs", "regular");
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-medium {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
|
|
|||
|
|
@ -58,9 +58,14 @@ div#driver-popover-item {
|
|||
}
|
||||
}
|
||||
|
||||
#driver-page-overlay {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#driver-highlighted-element-stage {
|
||||
background-color: var(--bg-color) !important;
|
||||
border-radius: var(--border-radius) !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
input.driver-highlighted-element {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@
|
|||
flex-wrap: wrap;
|
||||
color: var(--text-light);
|
||||
|
||||
.icon {
|
||||
stroke: var(--text-light);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
height: unset;
|
||||
}
|
||||
|
|
@ -38,6 +42,82 @@
|
|||
}
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: var(--padding-md);
|
||||
|
||||
.user-actions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-action-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-action-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: underline;
|
||||
justify-content: space-between;
|
||||
gap: var(--margin-sm);
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
margin-left: -6px;
|
||||
margin-right: -8px;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: background-color 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--subtle-fg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.user-action-external-icon {
|
||||
display: none;
|
||||
line-height: 0;
|
||||
|
||||
.icon {
|
||||
margin: 0;
|
||||
--icon-stroke: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
&[target="_blank"] .user-action-external-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transform: translateX(-2px);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
&[target="_blank"]:hover .user-action-external-icon,
|
||||
&[target="_blank"]:focus-visible .user-action-external-icon {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.user-action-label {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-section.user-actions.border-bottom {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-tags {
|
||||
.tag-area {
|
||||
margin-top: -3px;
|
||||
|
|
@ -141,8 +221,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
.form-title-text {
|
||||
// to match the actions button height for center alignment
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.form-stats-likes {
|
||||
gap: 8px;
|
||||
gap: 2px;
|
||||
.form-print {
|
||||
button:hover {
|
||||
background: var(--btn-default-hover-bg);
|
||||
|
|
@ -318,30 +403,56 @@ body[data-route^="Form"] {
|
|||
|
||||
.attachment-row,
|
||||
.form-tag-row {
|
||||
margin: var(--margin-xs) 0;
|
||||
max-width: 100%;
|
||||
margin: 4px 0;
|
||||
|
||||
.data-pill {
|
||||
@include get_textstyle("sm", "regular");
|
||||
justify-content: space-between;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0px 6px !important;
|
||||
|
||||
.pill-label {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
stroke: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
.attachment-row {
|
||||
margin-left: -6px;
|
||||
margin-right: 0px;
|
||||
|
||||
.data-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0px 6px !important;
|
||||
background-color: unset;
|
||||
box-shadow: none;
|
||||
padding-left: 0px !important;
|
||||
width: 100%;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background-color: var(--subtle-fg);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
> div {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-file-label {
|
||||
display: block;
|
||||
margin-left: var(--margin-xs);
|
||||
padding-right: var(--padding-xs);
|
||||
text-align: left;
|
||||
}
|
||||
.attachment-icon {
|
||||
|
|
@ -377,13 +488,58 @@ body[data-route^="Form"] {
|
|||
.form-attachments,
|
||||
.form-tags,
|
||||
.form-shared {
|
||||
padding: 8px;
|
||||
padding: var(--padding-sm) var(--padding-md);
|
||||
}
|
||||
|
||||
.form-attachments {
|
||||
// to add gap between attachment section label and attachments
|
||||
// without affecting empty state
|
||||
.attachments-actions + .attachment-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tags {
|
||||
// to add gap between tag section label and tags
|
||||
// without affecting empty state
|
||||
:not(.form-tag-row) + .form-tag-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-assignments,
|
||||
.form-shared {
|
||||
.assignments,
|
||||
.shares {
|
||||
margin: var(--margin-xs) 0px;
|
||||
margin-top: 8px;
|
||||
|
||||
.dialog-assignment-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0px 6px;
|
||||
margin-left: -8px;
|
||||
margin-right: 0px;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background-color: var(--subtle-fg);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-assignment {
|
||||
display: block;
|
||||
margin-top: var(--padding-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
.add-assignment-btn,
|
||||
|
|
@ -415,17 +571,43 @@ body[data-route^="Form"] {
|
|||
}
|
||||
}
|
||||
|
||||
.liked-by {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.liked-by-popover {
|
||||
max-width: 240px;
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
|
||||
.popover-body {
|
||||
min-height: 30px;
|
||||
padding: 0px;
|
||||
|
||||
.liked-by-popover-summary {
|
||||
padding: 4px 10px;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--subtle-accent);
|
||||
@include get_textstyle("sm", "regular");
|
||||
}
|
||||
|
||||
ul.list-unstyled {
|
||||
margin-bottom: 0px;
|
||||
padding: 4px;
|
||||
|
||||
li {
|
||||
padding: var(--padding-xs) var(--padding-sm);
|
||||
margin: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--padding-xs);
|
||||
padding: var(--padding-xs);
|
||||
margin: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--fg-hover-color);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,29 @@
|
|||
}
|
||||
}
|
||||
|
||||
.promotional-banners {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: var(--margin-sm) 0;
|
||||
}
|
||||
|
||||
.onboarding-sidebar,
|
||||
.promotional-banner {
|
||||
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);
|
||||
|
|
@ -272,9 +295,8 @@
|
|||
width: auto;
|
||||
}
|
||||
}
|
||||
.collapse-sidebar-link {
|
||||
display: none;
|
||||
}
|
||||
.promotional-banners,
|
||||
.collapse-sidebar-link,
|
||||
.dropdown-navbar-user {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,11 @@ $threshold: 34;
|
|||
max-width: var(--timeline-content-max-width);
|
||||
padding: var(--padding-sm);
|
||||
margin-left: var(--margin-md);
|
||||
|
||||
> .ql-editor {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
&.frappe-card {
|
||||
color: var(--text-neutral);
|
||||
background-color: var(--bg-color);
|
||||
|
|
|
|||
|
|
@ -1679,3 +1679,19 @@ class TestDataUtils(UnitTestCase):
|
|||
|
||||
self.assertEqual(comma_or(["a", "b", "c"]), "'a', 'b' ou 'c'")
|
||||
self.assertEqual(comma_or(["a", "b", "c"], add_quotes=False), "a, b ou c")
|
||||
|
||||
|
||||
class TestMsgPrint(UnitTestCase):
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
frappe.clear_messages()
|
||||
|
||||
def test_msgprint(self):
|
||||
frappe.msgprint("Validate: <script>alert('bounty')</script>")
|
||||
message = frappe.get_message_log()[-1]
|
||||
|
||||
self.assertNotIn("script", message.message)
|
||||
|
||||
frappe.msgprint("<ul><li>abc<li></ul>")
|
||||
message = frappe.get_message_log()[-1]
|
||||
self.assertIn("<ul><li>", message.message)
|
||||
|
|
|
|||
|
|
@ -530,14 +530,16 @@ def search(text: str, start: int = 0, limit: int = 20, doctype: str = ""):
|
|||
if r.doctype == doctype and r.rank > 0.0:
|
||||
try:
|
||||
meta = frappe.get_meta(r.doctype)
|
||||
doc = frappe.get_lazy_doc(r.doctype, r.name)
|
||||
if meta.image_field:
|
||||
r.image = frappe.db.get_value(r.doctype, r.name, meta.image_field)
|
||||
r.image = doc.get(meta.image_field)
|
||||
if meta.title_field:
|
||||
r.title = frappe.db.get_value(r.doctype, r.name, meta.title_field)
|
||||
r.title = doc.get(meta.title_field)
|
||||
except Exception:
|
||||
frappe.clear_messages()
|
||||
|
||||
sorted_results.extend([r])
|
||||
if doc.has_permission():
|
||||
sorted_results.append(r)
|
||||
|
||||
return sorted_results
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import functools
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
|
@ -8,8 +7,6 @@ from frappe import _
|
|||
from frappe.utils import strip_html_tags
|
||||
from frappe.utils.data import safe_decode
|
||||
|
||||
_strip_html_tags = functools.lru_cache(maxsize=1024)(strip_html_tags)
|
||||
|
||||
|
||||
def msgprint(
|
||||
msg: str | Sequence[str] | Sequence[Sequence[str]],
|
||||
|
|
@ -24,6 +21,7 @@ def msgprint(
|
|||
wide: bool = False,
|
||||
*,
|
||||
realtime=False,
|
||||
allow_dangerous_html=False,
|
||||
) -> None:
|
||||
"""Print a message to the user (via HTTP response).
|
||||
Messages are sent in the `__server_messages` property in the
|
||||
|
|
@ -38,9 +36,12 @@ def msgprint(
|
|||
:param is_minimizable: [optional] Allow users to minimize the modal
|
||||
:param wide: [optional] Show wide modal
|
||||
:param realtime: Publish message immediately using websocket.
|
||||
:param allow_dangerous_html: Allow arbitrary HTML in message.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from frappe.utils.html_utils import clean_html
|
||||
|
||||
msg = safe_decode(msg)
|
||||
out = frappe._dict(message=msg)
|
||||
|
||||
|
|
@ -67,13 +68,21 @@ def msgprint(
|
|||
else:
|
||||
out.as_table = as_table
|
||||
|
||||
if not allow_dangerous_html:
|
||||
if out.as_list:
|
||||
out.message = [clean_html(cell) for cell in msg]
|
||||
elif out.as_table:
|
||||
out.message = [[clean_html(cell) for cell in row] for row in msg]
|
||||
else:
|
||||
out.message = clean_html(msg)
|
||||
|
||||
if sys.stdin and sys.stdin.isatty():
|
||||
if out.as_list:
|
||||
msg = [_strip_html_tags(cell) for cell in msg]
|
||||
msg = [strip_html_tags(cell) for cell in msg]
|
||||
elif out.as_table:
|
||||
msg = [[_strip_html_tags(cell) for cell in row] for row in msg]
|
||||
msg = [[strip_html_tags(cell) for cell in row] for row in msg]
|
||||
else:
|
||||
msg = _strip_html_tags(msg)
|
||||
msg = strip_html_tags(msg)
|
||||
|
||||
if frappe.flags.print_messages and out.message:
|
||||
print(f"Message: {msg}")
|
||||
|
|
@ -134,6 +143,8 @@ def throw(
|
|||
wide: bool = False,
|
||||
as_list: bool = False,
|
||||
primary_action=None,
|
||||
*,
|
||||
allow_dangerous_html=False,
|
||||
) -> None:
|
||||
"""Throw execption and show message (`msgprint`).
|
||||
|
||||
|
|
@ -154,6 +165,7 @@ def throw(
|
|||
wide=wide,
|
||||
as_list=as_list,
|
||||
primary_action=primary_action,
|
||||
allow_dangerous_html=allow_dangerous_html,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -417,7 +417,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):
|
||||
|
|
@ -454,13 +454,10 @@ 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"
|
||||
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 = {}
|
||||
|
||||
|
|
@ -609,6 +606,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)
|
||||
|
|
@ -795,20 +800,16 @@ 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":
|
||||
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
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_in_list_view_fields(doctype: str):
|
||||
def get_in_list_view_fields(doctype, web_form_name=None):
|
||||
meta = frappe.get_meta(doctype)
|
||||
fields = []
|
||||
|
||||
|
|
@ -825,20 +826,43 @@ def get_in_list_view_fields(doctype: str):
|
|||
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":
|
||||
process_link_field(df, web_form_name)
|
||||
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 f.fieldtype == "Table" and f.options:
|
||||
child_doctype = f.options
|
||||
if not isinstance(child_doctype, str) or not child_doctype.strip():
|
||||
continue
|
||||
try:
|
||||
child_table_fields = frappe.get_meta(child_doctype).fields
|
||||
except Exception:
|
||||
continue
|
||||
for child_field in child_table_fields:
|
||||
if getattr(child_field, "options", None) == 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):
|
||||
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
|
||||
_("You don't have permission to access the {0} DocType.").format(doctype),
|
||||
frappe.PermissionError,
|
||||
)
|
||||
|
||||
link_options, filters = [], {}
|
||||
|
|
|
|||
2
frappe/website/js/bootstrap-4.js
vendored
2
frappe/website/js/bootstrap-4.js
vendored
|
|
@ -27,7 +27,7 @@ frappe.get_modal = function (title, content) {
|
|||
<div class="modal-header">
|
||||
<h5 class="modal-title">${title}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
${frappe.utils.icon("close-alt", "sm", "close-alt")}
|
||||
${frappe.utils.icon("x", "sm", "close-alt")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue