chore: resolve conflicts

This commit is contained in:
Aarol D'Souza 2026-01-27 19:52:53 +05:30 committed by AarDG10
commit 95450c85bd
172 changed files with 61988 additions and 51409 deletions

View file

@ -144,6 +144,14 @@ def is_frontend_code(file):
return file.lower().endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue", ".html", ".svg"))
def matches_postgres_filenames(files_list):
"""Check if any changed files suggest database involvement."""
db_keywords = ["database", "query", "schema", "postgres"]
return any(
any(word in f.lower() for word in db_keywords)
for f in files_list
)
def is_docs(file):
"""Check if the file is documentation or image."""
regex = re.compile(r"\.(md|png|jpg|jpeg|csv|svg)$|^.github|LICENSE")
@ -174,6 +182,10 @@ if __name__ == "__main__":
only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list)
updated_py_file_count = len(list(filter(is_server_side_code, files_list)))
only_py_changed = updated_py_file_count == len(files_list)
run_postgres = (
has_label(pr_number, "postgres", repo) or
matches_postgres_filenames(files_list)
)
# Check for Skip CI label and other conditions
if has_skip_ci_label(pr_number, repo):
@ -202,3 +214,4 @@ if __name__ == "__main__":
# If we reach here, run the build
os.system('echo "build=strawberry" >> $GITHUB_OUTPUT')
os.system(f'echo "run_postgres={"true" if run_postgres else "false"}" >> $GITHUB_OUTPUT')

View file

@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
branch: ["develop"]
branch: ["develop", "version-16-hotfix"]
permissions:
contents: write
@ -27,6 +27,11 @@ jobs:
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Run script to update POT file
run: |
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh

View file

@ -28,6 +28,7 @@ jobs:
needs: typecheck
outputs:
build: ${{ steps.check-build.outputs.build }}
run_postgres: ${{ steps.check-build.outputs.run_postgres }}
steps:
- name: Clone
uses: actions/checkout@v6
@ -44,7 +45,7 @@ jobs:
name: Tests
uses: ./.github/workflows/_base-server-tests.yml
with:
enable-postgres: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }} # This enables PostgreSQL to run tests
enable-postgres: ${{ needs.checkrun.outputs.run_postgres == 'true' }} # This enables PostgreSQL to run tests
enable-sqlite: false # This will test against both MariaDB and SQLite if enabled
parallel-runs: 2
enable-coverage: ${{ github.event_name != 'pull_request' }}

View file

@ -1,19 +1,22 @@
{
"branches": ["develop", {"name": "version-14-beta", "channel": "beta", "prerelease": true}],
"branches": ["version-17"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular"
"preset": "angular",
"releaseRules": [
{"breaking": true, "release": false}
]
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" frappe/__init__.py'
"prepareCmd": 'sed -ir -E "s/\"[0-9]+\.[0-9]+\.[0-9]+\"/\"${nextRelease.version}\"/" frappe/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["frappe/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}"
"message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"

View file

@ -27,7 +27,7 @@ Full-stack web application framework that uses Python and MariaDB on the server
Started in 2005, Frappe Framework was inspired by the Semantic Web. The "big idea" behind semantic web was of a framework that not only described how information is shown (like headings, body etc), but also what it means, like name, address etc.
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible.
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications are usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible.
The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.

View file

@ -11,7 +11,6 @@ context("Customize Form", () => {
"Set by user": "prompt",
"By fieldname": "field:",
Expression: "",
"Expression (old style)": "format:",
Random: "hash",
"By script": "",
};

View file

@ -196,7 +196,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force: bool =
local.cache = {}
local.form_dict = _dict()
local.preload_assets = {"style": [], "script": [], "icons": []}
local.session = _dict(user="Guest")
local.session = _dict(user="Guest", data=_dict())
local.dev_server = _dev_server # only for backwards compatibility
local.qb = get_query_builder(local.conf.db_type)
if not cache or not client_cache:

View file

@ -40,7 +40,7 @@ import gettext
import babel
import babel.messages
import bleach
import nh3
import num2words
import pydantic
@ -48,7 +48,7 @@ import frappe.boot
import frappe.client
import frappe.core.doctype.file.file
import frappe.core.doctype.user.user
import frappe.database.mariadb.database # Load database related utils
import frappe.database.mariadb.mysqlclient # Load database related utils
import frappe.database.query
import frappe.desk.desktop # workspace
import frappe.desk.form.save

View file

@ -161,8 +161,8 @@ def load_desktop_data(bootinfo):
from frappe.desk.desktop import get_workspace_sidebar_items
bootinfo.workspaces = get_workspace_sidebar_items()
bootinfo.workspace_sidebar_item = get_sidebar_items()
allowed_pages = [d.name for d in bootinfo.workspaces.get("pages")]
bootinfo.workspace_sidebar_item = get_sidebar_items(allowed_pages)
bootinfo.module_wise_workspaces = get_controller("Workspace").get_module_wise_workspaces()
bootinfo.dashboards = frappe.get_all("Dashboard")
bootinfo.app_data = []
@ -533,7 +533,7 @@ def get_sentry_dsn():
return os.getenv("FRAPPE_SENTRY_DSN")
def get_sidebar_items():
def get_sidebar_items(allowed_workspaces):
from frappe import _
from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import auto_generate_sidebar_from_module
@ -585,7 +585,7 @@ def get_sidebar_items():
if (
"My Workspaces" in sidebar_title
or si.type == "Section Break"
or w.is_item_allowed(si.link_to, si.link_type)
or w.is_item_allowed(si.link_to, si.link_type, allowed_workspaces)
):
sidebar_items[sidebar_title.lower()]["items"].append(workspace_sidebar)
add_user_specific_sidebar(sidebar_items)

View file

@ -10,7 +10,6 @@ import frappe.utils
from frappe import _
from frappe.desk.reportview import validate_args
from frappe.desk.search import PAGE_LENGTH_FOR_LINK_VALIDATION, search_widget
from frappe.model.utils import is_virtual_doctype
from frappe.utils import attach_expanded_links, get_safe_filters
from frappe.utils.caching import http_cache
@ -420,6 +419,7 @@ def validate_link_and_fetch(
if not docname:
frappe.throw(_("Document Name must not be empty"))
meta = frappe.get_meta(doctype)
fields_to_fetch = frappe.parse_json(fields_to_fetch)
# only cache is no fields to fetch and request is GET
@ -427,21 +427,31 @@ def validate_link_and_fetch(
# Use search_widget to validate - ensures filters/custom queries are respected
# in addition to standard permission checks
search_args["txt"] = docname
# we match the exact docname for non-custom queries and rely on txt for custom queries
search_args.update(
as_dict=False,
# when relying on txt (custom queries), we want to match "A" with "A" only and not "A1", "BA" etc.
# so we set page_length to a conservative value within which exact match is expected to appear
page_length=PAGE_LENGTH_FOR_LINK_VALIDATION,
# translated doctypes are expected to be searchable with translated values, even for custom queries
# for non-custom queries, docname is always matched exactly so we don't translate it
txt=_(docname) if (query and meta.translated_doctype) else docname,
for_link_validation=True,
)
search_result = frappe.call(
search_widget,
doctype=doctype,
query=query,
filters=filters,
**search_args,
for_link_validation=True,
)
if not search_result:
return {} # does not exist or filtered out
values = None
is_virtual_dt = is_virtual_doctype(doctype)
is_virtual_dt = bool(meta.get("is_virtual"))
if is_virtual_dt:
try:
doc = frappe.get_doc(doctype, docname)

View file

@ -1583,31 +1583,34 @@ def bypass_patch(context: CliCtxObj, patch_name: str, yes: bool):
frappe.destroy()
@click.command("create-desktop-icons-and-sidebar")
@click.command("sync-desktop-icons")
@pass_context
def create_icons_and_sidebar(context: CliCtxObj):
"""Create desktop icons and workspace sidebars."""
from frappe.desk.doctype.desktop_icon.desktop_icon import create_desktop_icons
from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import (
create_workspace_sidebar_for_workspaces,
)
def sync_desktop_icons(context: CliCtxObj):
from frappe.model.sync import import_file_by_path
from frappe.modules.utils import get_app_level_directory_path
from frappe.utils import update_progress_bar
if not context.sites:
raise SiteNotSpecifiedError
files = []
app_level_folders = ["desktop_icon"]
for site in context.sites:
print("Sycning icons for " + site)
frappe.init(site)
frappe.connect()
try:
print("Creating Desktop Icons")
create_desktop_icons()
print("Creating Workspace Sidebars")
create_workspace_sidebar_for_workspaces()
# Saving it in a command need it
frappe.db.commit() # nosemgrep
except Exception as e:
print(f"Error creating icons {site}: {e}")
finally:
frappe.destroy()
for app_name in frappe.get_installed_apps():
for folder_name in app_level_folders:
directory_path = get_app_level_directory_path(folder_name, app_name)
if os.path.exists(directory_path):
icon_files = [
os.path.join(directory_path, filename) for filename in os.listdir(directory_path)
]
for doc_path in icon_files:
files.append(doc_path)
for i, doc_path in enumerate(files):
imported = import_file_by_path(doc_path, force=True, ignore_version=True)
if imported:
frappe.db.commit(chain=True)
update_progress_bar("Updating Desktop Icons", i, len(files))
commands = [
@ -1646,5 +1649,5 @@ commands = [
trim_database,
clear_log_table,
bypass_patch,
create_icons_and_sidebar,
sync_desktop_icons,
]

View file

@ -366,7 +366,7 @@ def contact_query(doctype, txt, searchfield, start, page_len, filters):
order by
if(locate(%(_txt)s, `tabContact`.full_name), locate(%(_txt)s, `tabContact`.company_name), 99999),
`tabContact`.idx desc, `tabContact`.full_name
limit %(start)s, %(page_len)s """,
limit %(page_len)s offset %(start)s """,
{
"txt": "%" + txt + "%",
"_txt": txt.replace("%", ""),

View file

@ -72,6 +72,7 @@
"mandatory_depends_on",
"read_only_depends_on",
"display",
"alignment",
"print_width",
"width",
"max_height",
@ -475,6 +476,13 @@
"max_height": "3rem",
"options": "JS"
},
{
"depends_on": "eval:in_list([\"Data\", \"Int\", \"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
"fieldname": "alignment",
"fieldtype": "Select",
"label": "Alignment",
"options": "\nLeft\nCenter\nRight"
},
{
"fieldname": "column_break_38",
"fieldtype": "Column Break"

View file

@ -17,6 +17,7 @@ class DocField(Document):
allow_bulk_edit: DF.Check
allow_in_quick_entry: DF.Check
allow_on_submit: DF.Check
alignment: DF.Literal["", "Left", "Center", "Right"]
bold: DF.Check
button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"]
collapsible: DF.Check
@ -126,7 +127,6 @@ class DocField(Document):
def get_link_doctype(self):
"""Return the Link doctype for the `docfield` (if applicable).
* If fieldtype is Link: Return "options".
* If fieldtype is Table MultiSelect: Return "options" of the Link field in the Child Table.
"""

View file

@ -0,0 +1,5 @@
// Copyright (c) {year}, {app_publisher} and contributors
// For license information, please see license.txt
// frappe.treeview_settings["{doctype}"] = {{
// }};

View file

@ -877,6 +877,9 @@ class DocType(Document):
make_boilerplate("controller.js", self.as_dict())
# make_boilerplate("controller_list.js", self.as_dict())
if self.is_tree:
make_boilerplate("controller_tree.js", self.as_dict())
if self.has_web_view:
templates_path = frappe.get_module_path(
frappe.scrub(self.module), "doctype", frappe.scrub(self.name), "templates"
@ -1838,34 +1841,84 @@ def validate_permissions(doctype, for_remove=False, alert=False):
def check_permission_dependency(d):
if d.cancel and not d.submit:
frappe.throw(_("{0}: Cannot set Cancel without Submit").format(get_txt(d)))
frappe.throw(
_("{0}: The 'Cancel' permission cannot be granted without the 'Submit' permission.").format(
get_txt(d)
)
)
if (d.submit or d.cancel or d.amend) and not d.write:
frappe.throw(_("{0}: Cannot set Submit, Cancel, Amend without Write").format(get_txt(d)))
if d.amend and not d.write:
frappe.throw(_("{0}: Cannot set Amend without Cancel").format(get_txt(d)))
frappe.throw(
_(
"{0}: The 'Submit', 'Cancel', and 'Amend' permissions cannot be granted without the 'Write' permission."
).format(get_txt(d))
)
if d.amend and not d.create:
frappe.throw(
_("{0}: The 'Amend' permission cannot be granted without the 'Create' permission.").format(
get_txt(d)
)
)
if d.get("import") and not d.create:
frappe.throw(_("{0}: Cannot set Import without Create").format(get_txt(d)))
frappe.throw(
_("{0}: The 'Import' permission cannot be granted without the 'Create' permission.").format(
get_txt(d)
)
)
def remove_rights_for_single(d):
if not issingle:
return
if d.report:
frappe.msgprint(_("Report cannot be set for Single types"))
d.report = 0
if d.get("report"):
d.set("report", 0)
frappe.msgprint(
_(
"{0}: The 'Report' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
if d.get("import"):
d.set("import", 0)
frappe.msgprint(
_(
"{0}: The 'Import' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
if d.get("export"):
d.set("export", 0)
frappe.msgprint(
_(
"{0}: The 'Export' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
def check_if_submittable(d):
if d.submit and not issubmittable:
frappe.throw(_("{0}: Cannot set Assign Submit if not Submittable").format(get_txt(d)))
elif d.amend and not issubmittable:
frappe.throw(_("{0}: Cannot set Assign Amend if not Submittable").format(get_txt(d)))
if issubmittable:
return
if d.submit:
frappe.throw(
_("{0}: The 'Submit' permission cannot be granted for a non-submittable DocType.").format(
get_txt(d)
)
)
if d.amend:
frappe.throw(
_("{0}: The 'Amend' permission cannot be granted for a non-submittable DocType.").format(
get_txt(d)
)
)
def check_if_importable(d):
if d.get("import") and not isimportable:
frappe.throw(_("{0}: Cannot set import as {1} is not importable").format(get_txt(d), doctype))
frappe.throw(
_("{0}: The 'Import' permission cannot be granted for a non-importable DocType.").format(
get_txt(d)
)
)
def validate_permission_for_all_role(d):
if frappe.session.user == "Administrator":
@ -1875,7 +1928,7 @@ def validate_permissions(doctype, for_remove=False, alert=False):
if d.role in AUTOMATIC_ROLES:
frappe.throw(
_(
"Row # {0}: Non administrator user can not set the role {1} to the custom doctype"
"Row # {0}: Non-administrator users cannot add the role {1} to a custom DocType."
).format(d.idx, frappe.bold(_(d.role))),
title=_("Permissions Error"),
)
@ -1885,7 +1938,7 @@ def validate_permissions(doctype, for_remove=False, alert=False):
if d.role in roles:
frappe.throw(
_(
"Row # {0}: Non administrator user can not set the role {1} to the custom doctype"
"Row # {0}: Non-administrator users cannot add the role {1} to a custom DocType."
).format(d.idx, frappe.bold(_(d.role))),
title=_("Permissions Error"),
)

View file

@ -892,7 +892,7 @@ def has_permission(doc, ptype=None, user=None, debug=False):
try:
ref_doc = frappe.get_doc(attached_to_doctype, attached_to_name)
except ModuleNotFoundError:
except (ModuleNotFoundError, ImportError):
return False
except frappe.DoesNotExistError:
frappe.clear_last_message()

View file

@ -58,7 +58,7 @@
},
{
"fieldname": "error_message",
"fieldtype": "Text",
"fieldtype": "Code",
"label": "Error Message",
"no_copy": 1,
"print_hide": 1,

View file

@ -416,9 +416,10 @@ def get_report_module_dotted_path(module, report_name):
def get_group_by_field(args, doctype):
if args["aggregate_function"] == "count":
group_by_field = "count(*) as _aggregate_column"
group_by_field = {"COUNT": "*", "as": "_aggregate_column"}
else:
group_by_field = f"{args.aggregate_function}({args.aggregate_on}) as _aggregate_column"
func_name = args["aggregate_function"].upper()
group_by_field = {func_name: args["aggregate_on"], "as": "_aggregate_column"}
return group_by_field

View file

@ -786,10 +786,11 @@
"label": "Only allow System Managers to upload public files"
}
],
"hide_toolbar": 1,
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2025-12-17 15:01:24.823184",
"modified": "2026-01-02 18:13:45.430712",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -15,7 +15,9 @@ frappe.ui.form.on("User Permission", {
frm.set_query("applicable_for", () => {
return {
query: "frappe.core.doctype.user_permission.user_permission.get_applicable_for_doctype_list",
doctype: frm.doc.allow,
filters: {
doctype: frm.doc.allow,
},
};
});
},

View file

@ -161,7 +161,8 @@ def user_permission_exists(user, allow, for_value, applicable_for=None):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_applicable_for_doctype_list(doctype, txt, searchfield, start, page_len, filters):
linked_doctypes_map = get_linked_doctypes(doctype, True)
actual_doctype = filters.get("doctype")
linked_doctypes_map = get_linked_doctypes(actual_doctype, True)
linked_doctypes = []
for linked_doctype, linked_doctype_values in linked_doctypes_map.items():
@ -170,7 +171,7 @@ def get_applicable_for_doctype_list(doctype, txt, searchfield, start, page_len,
if child_doctype:
linked_doctypes.append(child_doctype)
linked_doctypes += [doctype]
linked_doctypes += [actual_doctype]
if txt:
linked_doctypes = [d for d in linked_doctypes if txt.lower() in d.lower()]

View file

@ -3,12 +3,137 @@
import copy
import frappe
from frappe.core.doctype.version.version import get_diff
from frappe.tests import IntegrationTestCase
from frappe.core.doctype.version.version import (
_as_string,
_generate_html_diff,
_should_generate_html_diff,
get_diff,
)
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests.utils import make_test_objects
class TestHTMLDiff(UnitTestCase):
def test_generate_html_diff_produces_table(self):
"""Test HTML diff generates a table with content."""
result = _generate_html_diff("line1\nline2", "line1\nmodified")
self.assertIsNotNone(result)
self.assertIn("<table", result)
self.assertIn("line1", result)
def test_generate_html_diff_escapes_html(self):
"""Test HTML output is properly escaped and safe."""
old_value = "<script>alert('xss')</script>\nline2"
new_value = "<div>injected</div>\nline2"
result = _generate_html_diff(old_value, new_value)
self.assertIsNotNone(result)
# Raw script/div tags should be escaped, not executable
self.assertNotIn("<script>alert", result)
self.assertNotIn("<div>injected", result)
# Escaped versions should be present
self.assertIn("&lt;script&gt;", result)
self.assertIn("&lt;div&gt;", result)
def test_should_generate_html_diff_multiline(self):
"""Test should_generate_html_diff returns True for multiline text."""
self.assertTrue(_should_generate_html_diff("line1\nline2", "line1\nmodified"))
self.assertTrue(_should_generate_html_diff("single", "multi\nline"))
self.assertTrue(_should_generate_html_diff("multi\nline", "single"))
def test_should_generate_html_diff_long_text(self):
"""Test should_generate_html_diff returns True for text > 80 characters."""
self.assertTrue(_should_generate_html_diff("a" * 81, "b"))
self.assertTrue(_should_generate_html_diff("a", "b" * 81))
self.assertTrue(_should_generate_html_diff("a" * 81, "b" * 81))
def test_should_generate_html_diff_short_text(self):
"""Test should_generate_html_diff returns False for short single-line text."""
self.assertFalse(_should_generate_html_diff("short", "text"))
self.assertFalse(_should_generate_html_diff("a" * 80, "b" * 80)) # Exactly 80 chars
def test_should_generate_html_diff_empty_values(self):
"""Test should_generate_html_diff returns False when either value is empty."""
self.assertFalse(_should_generate_html_diff("", "short"))
self.assertFalse(_should_generate_html_diff("short", ""))
self.assertFalse(_should_generate_html_diff("", ""))
# Even long/multiline text returns False if the other value is empty
self.assertFalse(_should_generate_html_diff("", "a" * 81))
self.assertFalse(_should_generate_html_diff("multi\nline", ""))
def test_as_string_converts_values(self):
"""Test _as_string converts values to strings correctly."""
self.assertEqual(_as_string("text"), "text")
self.assertEqual(_as_string(None), "")
self.assertEqual(_as_string(""), "")
self.assertEqual(_as_string(0), "0")
class TestVersion(IntegrationTestCase):
def test_onload_generates_html_diffs_for_multiline(self):
"""Test onload generates HTML diffs for multiline changes."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["description", "line1\nline2", "line1\nmodified"]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNotNone(html_diffs)
self.assertIn("description", html_diffs)
self.assertIn("<table", html_diffs["description"])
def test_onload_generates_html_diffs_for_long_text(self):
"""Test onload generates HTML diffs for text > 80 characters."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["notes", "x" * 81, "y" * 81]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNotNone(html_diffs)
self.assertIn("notes", html_diffs)
def test_onload_no_html_diffs_for_simple_changes(self):
"""Test onload doesn't generate HTML diffs for simple short changes."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["status", "Open", "Closed"]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNone(html_diffs)
def test_onload_handles_empty_data(self):
"""Test onload handles empty or missing data gracefully."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=None,
)
# Should not raise an error
version.onload()
self.assertIsNone(version.get_onload().get("html_diffs"))
version.data = frappe.as_json({"changed": []})
version.onload()
self.assertIsNone(version.get_onload().get("html_diffs"))
def test_get_diff(self):
frappe.set_user("Administrator")
test_records = make_test_objects("Event", reset=True)

View file

@ -1,12 +1,23 @@
frappe.ui.form.on("Version", "refresh", function (frm) {
$(
frappe.render_template("version_view", { doc: frm.doc, data: JSON.parse(frm.doc.data) })
).appendTo(frm.fields_dict.table_html.$wrapper.empty());
frm.add_custom_button(__("Show all Versions"), function () {
frappe.set_route("List", "Version", {
ref_doctype: frm.doc.ref_doctype,
docname: frm.doc.docname,
frappe.ui.form.on("Version", {
refresh: function (frm) {
frm.add_custom_button(__("Show all Versions"), function () {
frappe.set_route("List", "Version", {
ref_doctype: frm.doc.ref_doctype,
docname: frm.doc.docname,
});
});
});
frm.trigger("render_version_view");
},
render_version_view: async function (frm) {
await frappe.model.with_doctype(frm.doc.ref_doctype);
$(
frappe.render_template("version_view", {
doc: frm.doc,
data: JSON.parse(frm.doc.data),
})
).appendTo(frm.fields_dict.table_html.$wrapper.empty());
},
});

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import difflib
import json
import frappe
@ -74,6 +75,29 @@ class Version(Document):
def get_data(self):
return json.loads(self.data)
def onload(self):
"""Generate HTML diffs for multiline changes on document load."""
if not self.data:
return
data = self.get_data()
changed = data.get("changed", [])
if not changed:
return
html_diffs = {}
for item in changed:
if len(item) >= 3:
fieldname, old_str, new_str = item[0], _as_string(item[1]), _as_string(item[2])
if not _should_generate_html_diff(old_str, new_str):
continue
html_diff = _generate_html_diff(old_str, new_str)
if html_diff:
html_diffs[fieldname] = html_diff
if html_diffs:
self.set_onload("html_diffs", html_diffs)
def get_diff(old, new, for_child=False, compare_cancelled=False):
"""Get diff between 2 document objects
@ -203,3 +227,32 @@ def get_diff(old, new, for_child=False, compare_cancelled=False):
def on_doctype_update():
frappe.db.add_index("Version", ["ref_doctype", "docname"])
def _generate_html_diff(old_str: str, new_str: str) -> str | None:
"""Generate HTML diff for the given old and new strings."""
old_lines = old_str.splitlines(keepends=True)
new_lines = new_str.splitlines(keepends=True)
differ = difflib.HtmlDiff(wrapcolumn=80)
html_diff = differ.make_table(
old_lines,
new_lines,
fromdesc=frappe._("Original"),
todesc=frappe._("New"),
context=True,
numlines=3,
)
return html_diff
def _should_generate_html_diff(old_str: str, new_str: str) -> bool:
"""Determine if HTML diff should be generated for the given values."""
return (
old_str and new_str and ("\n" in old_str or "\n" in new_str or len(old_str) > 80 or len(new_str) > 80)
)
def _as_string(value: str | None) -> str:
"""Convert the given value to a string."""
return cstr(value) if value is not None else ""

View file

@ -1,3 +1,52 @@
<style>
.version-diff-container {
margin-bottom: 1rem;
}
.version-diff-container h5 {
margin-bottom: 0.5rem;
}
.version-html-diff table.diff {
width: 100%;
border-collapse: collapse;
font-family: monospace;
font-size: 12px;
}
.version-html-diff table.diff td {
padding: 2px 6px;
border: 1px solid var(--border-color);
vertical-align: top;
}
.version-html-diff table.diff .diff_header {
background-color: var(--subtle-fg);
text-align: right;
padding: 2px 6px;
color: var(--text-muted);
font-weight: normal;
width: 40px;
}
.version-html-diff table.diff .diff_next {
background-color: var(--subtle-fg);
width: 10px;
}
.version-html-diff table.diff .diff_add {
background-color: var(--diff-added);
}
.version-html-diff table.diff .diff_chg {
background-color: var(--diff-changed);
}
.version-html-diff table.diff .diff_sub {
background-color: var(--diff-removed);
}
.version-html-diff table.diff th {
background-color: var(--subtle-fg);
padding: 6px;
border: 1px solid var(--border-color);
font-weight: 500;
}
.version-html-diff table.diff colgroup {
display: none;
}
</style>
<div class="version-info">
{% if data.comment %}
<h4>{{ __("Comment") + " (" + data.comment_type }})</h4>
@ -5,8 +54,19 @@
{% endif %}
{% const getEscapedValue = (v) => v === null ? "null" : frappe.utils.escape_html(v) %}
{% const htmlDiffs = (doc.__onload && doc.__onload.html_diffs) || {} %}
{% if data.changed && data.changed.length %}
<h4>{{ __("Values Changed") }}</h4>
{% for item in data.changed %}
{% if htmlDiffs[item[0]] %}
<div class="version-diff-container">
<h5>{{ frappe.meta.get_label(doc.ref_doctype, item[0]) }}</h5>
<div class="version-html-diff">{{ htmlDiffs[item[0]] }}</div>
</div>
{% endif %}
{% endfor %}
{% var hasSimpleChanges = data.changed.some(item => !htmlDiffs[item[0]]) %}
{% if hasSimpleChanges %}
<table class="table table-bordered">
<thead>
<tr>
@ -17,15 +77,18 @@
</thead>
<tbody>
{% for item in data.changed %}
{% if !htmlDiffs[item[0]] %}
<tr>
<td>{{ frappe.meta.get_label(doc.ref_doctype, item[0]) }}</td>
<td class="diff-remove">{{ getEscapedValue(item[1]) }}</td>
<td class="diff-add">{{ getEscapedValue(item[2]) }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}
{% endif %}
{% var _keys = ["added", "removed"]; %}
{% for key in _keys %}

View file

@ -138,11 +138,12 @@ frappe.PermissionEngine = class PermissionEngine {
.concat(custom_rights)
.map((r) => {
return __(toTitle(frappe.unscrub(r)));
});
})
.join(", ");
$wrapper.append(`<div class="row">\
<div class="col-xs-5"><b>${__(d.role)}</b>, ${__("Level")} ${d.permlevel || 0}</div>\
<div class="col-xs-7">${d.rights}</div>\
<div class="col-xs-7 text-break">${d.rights}</div>\
</div><br>`);
});
}
@ -309,6 +310,7 @@ frappe.PermissionEngine = class PermissionEngine {
.attr("data-doctype", d.parent);
checkbox.find("label").css("text-transform", "capitalize");
checkbox.find("label").css("align-items", "center");
return checkbox;
}
@ -415,7 +417,7 @@ frappe.PermissionEngine = class PermissionEngine {
add_delete_button(row, d) {
$(
`<button class='btn btn-danger btn-remove-perm btn-xs'>${frappe.utils.icon(
"delete"
"x"
)}</button>`
)
.appendTo($(`<td class="pt-4">`).appendTo(row))

View file

@ -46,6 +46,7 @@
"print_hide",
"print_hide_if_no_value",
"print_width",
"alignment",
"no_copy",
"allow_on_submit",
"in_list_view",
@ -302,6 +303,13 @@
"no_copy": 1,
"print_hide": 1
},
{
"depends_on": "eval:['Data', 'Int', 'Float', 'Currency', 'Percent'].includes(doc.fieldtype)",
"fieldname": "alignment",
"fieldtype": "Select",
"label": "Alignment",
"options": "\nLeft\nCenter\nRight"
},
{
"default": "0",
"fieldname": "no_copy",

View file

@ -24,6 +24,7 @@ class CustomField(Document):
allow_in_quick_entry: DF.Check
allow_on_submit: DF.Check
alignment: DF.Literal["", "Left", "Center", "Right"]
bold: DF.Check
button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"]
collapsible: DF.Check

View file

@ -768,6 +768,7 @@ docfield_properties = {
"permlevel": "Int",
"width": "Data",
"print_width": "Data",
"alignment": "Select",
"non_negative": "Check",
"reqd": "Check",
"unique": "Check",

View file

@ -65,6 +65,7 @@
"print_hide",
"print_hide_if_no_value",
"print_width",
"alignment",
"columns",
"width",
"is_custom_field"
@ -356,6 +357,13 @@
"print_width": "50px",
"width": "50px"
},
{
"depends_on": "eval:in_list(['Data', 'Int', 'Float', 'Currency', 'Percent'], doc.fieldtype)",
"fieldname": "alignment",
"fieldtype": "Select",
"label": "Alignment",
"options": "\nLeft\nCenter\nRight"
},
{
"depends_on": "eval:parent.istable",
"description": "Number of columns for a field in a Grid (Total Columns in a grid should be less than 11)",

View file

@ -16,6 +16,7 @@ class CustomizeFormField(Document):
allow_bulk_edit: DF.Check
allow_in_quick_entry: DF.Check
allow_on_submit: DF.Check
alignment: DF.Literal["", "Left", "Center", "Right"]
bold: DF.Check
button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"]
collapsible: DF.Check

View file

@ -1637,6 +1637,24 @@ class Engine:
meta = frappe.get_meta(doctype)
df = meta.get_field(fieldname)
except Exception:
if frappe.db.db_type == "postgres":
"""check type and accordingly choose fallback (to avoid postgres type cast errors)"""
target_table = frappe.utils.get_table_name(doctype)
info_schema = frappe.qb.Schema("information_schema")
columns = info_schema.columns
current_schema = frappe.conf.get("db_schema", "public")
res = (
frappe.qb.from_(columns)
.select(columns.data_type)
.where(
(columns.table_name == target_table)
& (columns.column_name == fieldname)
& (columns.table_schema == current_schema)
)
).run(pluck=True)
data_type = res[0] if res else None
if data_type in ("smallint", "bigint", "int", "numeric"): # can add as needed
return "0"
return "''"
if df is None:

View file

@ -458,14 +458,6 @@ def get_workspace_sidebar_items():
pages = []
private_pages = []
# get additional settings from Work Settings
try:
workspace_visibilty = loads(
frappe.db.get_single_value("Workspace Settings", "workspace_visibility_json") or "{}"
)
except JSONDecodeError:
workspace_visibilty = {}
# Filter Page based on Permission
for page in all_pages:
try:
@ -477,9 +469,6 @@ def get_workspace_sidebar_items():
private_pages.append(page)
page["label"] = _(page.get("name"))
if page["name"] in workspace_visibilty:
page["visibility"] = workspace_visibilty[page["name"]]
if not page["app"] and page["module"]:
page["app"] = frappe.db.get_value("Module Def", page["module"], "app_name") or get_module_app(
page["module"]
@ -502,9 +491,6 @@ def get_workspace_sidebar_items():
pages.append(next((x for x in all_pages if x["title"] == "Welcome Workspace"), None))
return {
"workspace_setup_completed": frappe.db.get_single_value(
"Workspace Settings", "workspace_setup_completed"
),
"pages": pages,
"has_access": has_access,
"has_create_access": frappe.has_permission(doctype="Workspace", ptype="create"),

View file

@ -218,7 +218,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
else get_period(r[0], timegrain)
for r in result
],
"datasets": [{"name": chart.name, "values": [r[1] for r in result]}],
"datasets": [{"name": _(chart.name), "values": [r[1] for r in result]}],
}
@ -292,7 +292,7 @@ def get_group_by_chart_config(chart, filters) -> dict | None:
if data:
return {
"labels": [item.get("name", "Not Specified") for item in data],
"datasets": [{"name": chart.name, "values": [item["count"] for item in data]}],
"datasets": [{"name": _(chart.name), "values": [item["count"] for item in data]}],
}
return None

View file

@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:label",
"creation": "2016-02-22 03:47:45.387068",
@ -56,6 +57,7 @@
{
"fieldname": "icon",
"fieldtype": "Icon",
"hidden": 1,
"label": "Icon"
},
{
@ -142,7 +144,7 @@
}
],
"links": [],
"modified": "2026-01-01 19:41:40.557973",
"modified": "2026-01-25 15:29:33.884930",
"modified_by": "Administrator",
"module": "Desk",
"name": "Desktop Icon",
@ -160,6 +162,18 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Desk User",
"share": 1,
"write": 1
}
],
"quick_entry": 1,

View file

@ -47,25 +47,30 @@ class DesktopIcon(Document):
def on_trash(self):
clear_desktop_icons_cache()
if frappe.conf.developer_mode and self.standard and self.app:
self.delete_desktop_icon_file()
delete_desktop_icon_file(self.app, self.label)
def check_for_restrict_removal(self):
if self.restrict_removal:
frappe.throw(_("Cannot delete Desktop Icon '{0}' as it is restricted").format(self.label))
def on_update(self):
self.export_desktop_icon()
def after_rename(self, old, new, merge):
delete_desktop_icon_file(self.app, old)
self.export_desktop_icon()
def export_desktop_icon(self):
allow_export = (
self.standard and self.app and not frappe.flags.in_import and frappe.conf.developer_mode
)
if allow_export:
self.export_desktop_icon()
def export_desktop_icon(self):
folder_path = create_directory_on_app_path("desktop_icon", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.label)}.json")
doc_export = self.as_dict(no_nulls=True, no_private_properties=True)
strip_default_fields(self, doc_export)
# if self.parent_icon:
# print(self.parent_icon)
# doc_export["parent_icon"] = frappe.db.get_value("Desktop Icon", self.parent_icon, "label")
with open(file_path, "w+") as icon_file_doc:
icon_file_doc.write(frappe.as_json(doc_export) + "\n")
folder_path = create_directory_on_app_path("desktop_icon", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.label)}.json")
doc_export = self.as_dict(no_nulls=True, no_private_properties=True)
strip_default_fields(self, doc_export)
with open(file_path, "w+") as icon_file_doc:
icon_file_doc.write(frappe.as_json(doc_export) + "\n")
def delete_desktop_icon_file(self):
folder_path = create_directory_on_app_path("desktop_icon", self.app)
@ -81,16 +86,13 @@ class DesktopIcon(Document):
else:
try:
items = bootinfo.workspace_sidebar_item[self.label.lower()]["items"]
#
if len(items) == 0:
return False
if len(items) and all(item["type"] == "Section Break" for item in items):
return False
return True
except KeyError:
return False
return True
def check_app_permission(self):
for a in frappe.get_installed_apps():
@ -121,6 +123,13 @@ class DesktopIcon(Document):
clear_desktop_icons_cache()
def delete_desktop_icon_file(app, label):
folder_path = create_directory_on_app_path("desktop_icon", app)
file_path = os.path.join(folder_path, f"{frappe.scrub(label)}.json")
if os.path.exists(file_path):
os.remove(file_path)
def get_workspace_names(workspaces):
workspace_list = []
for w in workspaces["pages"]:
@ -150,85 +159,37 @@ def get_desktop_icons(user=None, bootinfo=None):
"logo_url",
"hidden",
"name",
"sidebar",
]
active_domains = frappe.get_active_domains()
DocType = frappe.qb.DocType("DocType")
if active_domains:
blocked_condition = (
(DocType.restrict_to_domain.isnull())
| (DocType.restrict_to_domain == "")
| (DocType.restrict_to_domain.notin(active_domains))
)
else:
blocked_condition = (DocType.restrict_to_domain.isnull()) | (DocType.restrict_to_domain == "")
blocked_doctypes = [
d.get("name")
for d in frappe.qb.from_(DocType).select(DocType.name).where(blocked_condition).run(as_dict=True)
"restrict_removal",
"icon_image",
]
standard_icons = frappe.get_all("Desktop Icon", fields=fields, filters={"standard": 1})
standard_map = {}
for icon in standard_icons:
if icon._doctype in blocked_doctypes:
icon.blocked = 1
standard_map[icon.module_name] = icon
user_icons = frappe.get_all("Desktop Icon", fields=fields, filters={"standard": 0, "owner": user})
user_icons = user_icons + standard_icons
# for icon in user_icons:
# standard_icon = standard_map.get(icon.module_name, None)
# update hidden property
for icon in user_icons:
standard_icon = standard_map.get(icon.module_name, None)
# # override properties from standard icon
# if standard_icon:
# for key in ("route", "label", "color", "icon", "link"):
# if standard_icon.get(key):
# icon[key] = standard_icon.get(key)
if icon._doctype in blocked_doctypes:
icon.blocked = 1
# if standard_icon.blocked:
# icon.hidden = 1
# override properties from standard icon
if standard_icon:
for key in ("route", "label", "color", "icon", "link"):
if standard_icon.get(key):
icon[key] = standard_icon.get(key)
# # flag for modules_select dialog
# icon.hidden_in_standard = 1
if standard_icon.blocked:
icon.hidden = 1
# flag for modules_select dialog
icon.hidden_in_standard = 1
elif standard_icon.force_show:
icon.hidden = 0
# add missing standard icons (added via new install apps?)
user_icon_names = [icon.module_name for icon in user_icons]
for standard_icon in standard_icons:
if standard_icon.module_name not in user_icon_names:
# if blocked, hidden too!
if standard_icon.blocked:
standard_icon.hidden = 1
standard_icon.hidden_in_standard = 1
user_icons.append(standard_icon)
user_blocked_modules = frappe.get_lazy_doc("User", user).get_blocked_modules()
for icon in user_icons:
if icon.module_name in user_blocked_modules:
icon.hidden = 1
# elif standard_icon.force_show:
# icon.hidden = 0
# sort by idx
user_icons.sort(key=lambda a: a.idx)
# translate
# for d in user_icons:
# if d.label:
# d.label = _(d.label, context=d.parent)
# includes
permitted_icons = []
permitted_parent_labels = set()
if bootinfo:
for s in user_icons:
icon = frappe.get_doc("Desktop Icon", s)
@ -263,7 +224,6 @@ def create_desktop_icons_from_workspace():
icon.link_type = "Workspace Sidebar"
icon.label = w.name
icon.icon_type = "Link"
icon.standard = 1
icon.link_to = w.name
icon.icon = w.icon
if w.module:
@ -309,7 +269,6 @@ def create_desktop_icons_from_installed_apps():
icon = frappe.new_doc("Desktop Icon")
icon.label = app_title
icon.link_type = "External"
icon.standard = 1
icon.idx = index
icon.icon_type = "App"
icon.app = a
@ -323,3 +282,23 @@ def create_desktop_icons_from_installed_apps():
def create_desktop_icons():
create_desktop_icons_from_installed_apps()
create_desktop_icons_from_workspace()
def create_user_icons(user, data):
user_settings = json.loads(data)
new_icons = user_settings.get("icons_to_create")
if new_icons:
new_icons = json.loads(user_settings.get("icons_to_create"))
if new_icons:
for icon in new_icons:
try:
desktop_icon = frappe.new_doc("Desktop Icon")
desktop_icon.update(icon)
desktop_icon.owner = user
desktop_icon.save()
except Exception as e:
frappe.log_error("Error in syncing icons", e)
user_settings.pop("icons_to_create", None)
frappe.cache.hset("_user_settings", f"{'Desktop Icon'}::{user}", json.dumps(user_settings))
return json.dumps(user_settings)
return data

View file

@ -0,0 +1,8 @@
// Copyright (c) 2026, Frappe Technologies and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Desktop Layout", {
// refresh(frm) {
// },
// });

View file

@ -0,0 +1,67 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:user",
"creation": "2026-01-18 02:17:17.304705",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"user",
"layout"
],
"fields": [
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User",
"unique": 1
},
{
"fieldname": "layout",
"fieldtype": "Code",
"label": "Layout",
"options": "JSON"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-01-25 15:30:12.805037",
"modified_by": "Administrator",
"module": "Desk",
"name": "Desktop Layout",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Desk User",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,50 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
import json
import frappe
from frappe.model.document import Document
class DesktopLayout(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
layout: DF.Code | None
user: DF.Link | None
# end: auto-generated types
pass
@frappe.whitelist()
def save_layout(user, layout, new_icons):
if not user:
user = frappe.session.user
layout = json.loads(layout)
new_icons = json.loads(new_icons)
desktop_layout = None
try:
desktop_layout = frappe.get_doc("Desktop Layout", frappe.session.user)
except frappe.DoesNotExistError:
frappe.clear_last_message()
desktop_layout = frappe.new_doc("Desktop Layout")
desktop_layout.user = frappe.session.user
if layout:
desktop_layout.layout = json.dumps(layout)
desktop_layout.save()
for icon in new_icons:
desktop_icon = frappe.new_doc("Desktop Icon")
desktop_icon.update(icon)
desktop_icon.owner = frappe.session.user
desktop_icon.save()
return {"layout": layout}

View file

@ -0,0 +1,20 @@
# Copyright (c) 2026, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestDesktopLayout(IntegrationTestCase):
"""
Integration tests for DesktopLayout.
Use this class for testing interactions between multiple components.
"""
pass

View file

@ -307,7 +307,8 @@ def new_page(new_page):
# add to workspace sidebar items
if not doc.public:
add_to_my_workspace(doc)
return {"workspace_pages": get_workspace_sidebar_items(), "sidebar_items": get_sidebar_items()}
workspaces = get_workspace_sidebar_items()
return {"workspace_pages": workspaces, "sidebar_items": get_sidebar_items(workspaces)}
@frappe.whitelist()

View file

@ -1,9 +0,0 @@
# Copyright (c) 2024, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
class TestWorkspaceSettings(IntegrationTestCase):
pass

View file

@ -1,37 +0,0 @@
// Copyright (c) 2024, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Workspace Settings", {
setup(frm) {
frm.hide_full_form_button = true;
frm.docfields = [];
frm.workspace_map = {};
let workspace_visibilty = JSON.parse(frm.doc.workspace_visibility_json || "{}");
// build fields from workspaces
let cnt = 0,
column_added = false;
for (let page of frappe.boot.allowed_workspaces) {
if (page.public) {
frm.workspace_map[page.name] = page;
cnt++;
frm.docfields.push({
fieldtype: "Check",
fieldname: page.name,
label: page.title + (page.parent_page ? ` (${page.parent_page})` : ""),
initial_value: workspace_visibilty[page.name] !== 0, // not set is also visible
});
}
}
frappe.temp = frm;
},
validate(frm) {
frm.doc.workspace_visibility_json = JSON.stringify(frm.dialog.get_values());
frm.doc.workspace_setup_completed = 1;
},
after_save(frm) {
// reload page to show latest sidebar
frappe.app.sidebar.reload();
},
});

View file

@ -1,66 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-08-02 14:20:30.177818",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"select_workspaces_section",
"workspace_visibility_json",
"workspace_setup_completed"
],
"fields": [
{
"fieldname": "select_workspaces_section",
"fieldtype": "Section Break",
"label": "Select Workspaces"
},
{
"fieldname": "workspace_visibility_json",
"fieldtype": "JSON",
"in_list_view": 1,
"label": "Workspace Visibility",
"reqd": 1
},
{
"default": "0",
"fieldname": "workspace_setup_completed",
"fieldtype": "Check",
"label": "Workspace Setup Completed"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-09-03 21:29:54.127014",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Workspace Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -1,41 +0,0 @@
# Copyright (c) 2024, Frappe Technologies and contributors
# For license information, please see license.txt
import json
import frappe
from frappe.model.document import Document
class WorkspaceSettings(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
workspace_setup_completed: DF.Check
workspace_visibility_json: DF.JSON
# end: auto-generated types
pass
def on_update(self):
frappe.clear_cache()
@frappe.whitelist()
def set_sequence(sidebar_items):
if not WorkspaceSettings("Workspace Settings").has_permission():
frappe.throw_permission_error()
cnt = 1
for item in json.loads(sidebar_items):
frappe.db.set_value("Workspace", item.get("name"), "sequence_id", cnt)
frappe.db.set_value("Workspace", item.get("name"), "parent_page", item.get("parent") or "")
cnt += 1
frappe.clear_cache()
frappe.toast(frappe._("Updated"))

View file

@ -50,34 +50,31 @@ class WorkspaceSidebar(Document):
self.user.build_permissions()
def before_save(self):
allow_export = self.app and not frappe.flags.in_import and frappe.conf.developer_mode
if allow_export:
self.export_sidebar()
self.export_sidebar()
self.set_module()
def export_sidebar(self):
folder_path = create_directory_on_app_path("workspace_sidebar", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.title)}.json")
doc_export = self.as_dict(no_nulls=True, no_private_properties=True)
doc_export = strip_default_fields(self, doc_export)
with open(file_path, "w+") as doc_file:
doc_file.write(frappe.as_json(doc_export) + "\n")
def delete_file(self):
folder_path = create_directory_on_app_path("workspace_sidebar", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.title)}.json")
if os.path.exists(file_path):
os.remove(file_path)
allow_export = self.app and not frappe.flags.in_import and frappe.conf.developer_mode
if allow_export:
folder_path = create_directory_on_app_path("workspace_sidebar", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.title)}.json")
doc_export = self.as_dict(no_nulls=True, no_private_properties=True)
doc_export = strip_default_fields(self, doc_export)
with open(file_path, "w+") as doc_file:
doc_file.write(frappe.as_json(doc_export) + "\n")
def on_trash(self):
if is_workspace_manager():
if frappe.conf.developer_mode and self.app:
self.delete_file()
self.delete_desktop_icon()
delete_file(self.app, self.title)
else:
frappe.throw(_("You need to be Workspace Manager to delete a public workspace."))
def is_item_allowed(self, name, item_type):
def after_rename(self, old, new, merge):
delete_file(self.app, old)
self.export_sidebar()
def is_item_allowed(self, name, item_type, allowed_workspaces):
if frappe.session.user == "Administrator":
return True
@ -100,12 +97,7 @@ class WorkspaceSidebar(Document):
if item_type == "url":
return True
if item_type == "workspace":
try:
workspace = frappe.get_cached_doc("Workspace", name)
if workspace.module in self.allowed_modules:
return True
except frappe.DoesNotExistError:
return False
return name in allowed_workspaces
def get_cached(self, cache_key, fallback_fn):
value = frappe.cache.get_value(cache_key, user=frappe.session.user)
@ -137,16 +129,6 @@ class WorkspaceSidebar(Document):
if counts and counts.most_common(1)[0]:
return counts.most_common(1)[0][0]
def delete_desktop_icon(self):
desktop_icon = frappe.get_all(
"Desktop Icon",
filters=[{"link_type": "Workspace Sidebar"}, {"link_to": self.name}],
limit=1,
pluck="name",
)
if desktop_icon:
frappe.delete_doc("Desktop Icon", desktop_icon[0])
def get_allowed_modules(self):
if not self.user.allow_modules:
self.user.build_permissions()
@ -154,6 +136,13 @@ class WorkspaceSidebar(Document):
return self.user.allow_modules
def delete_file(app, title):
folder_path = create_directory_on_app_path("workspace_sidebar", app)
file_path = os.path.join(folder_path, f"{frappe.scrub(title)}.json")
if os.path.exists(file_path):
os.remove(file_path)
def is_workspace_manager():
return "Workspace Manager" in frappe.get_roles()
@ -339,7 +328,7 @@ def choose_top_doctypes(doctype_names):
try:
doctype_count_map = {}
for doctype in doctype_names:
if not is_single_doctype(doctype):
if not is_single_doctype(doctype) and not frappe.get_meta(doctype).is_virtual:
doctype_count_map[doctype] = frappe.db.count(doctype)
top_doctypes = [
name

View file

@ -1,5 +1,8 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from __future__ import annotations
from typing import TYPE_CHECKING
import frappe
import frappe.utils
@ -8,6 +11,9 @@ from frappe.model import log_types
from frappe.query_builder import DocType
from frappe.utils import get_url_to_form
if TYPE_CHECKING:
from frappe.model.document import Document
@frappe.whitelist()
def update_follow(doctype: str, doc_name: str, following: bool):
@ -18,7 +24,7 @@ def update_follow(doctype: str, doc_name: str, following: bool):
@frappe.whitelist()
def follow_document(doctype, doc_name, user):
def follow_document(doctype: str, doc_name: str, user: str) -> Document | bool:
"""
param:
Doctype name
@ -67,7 +73,7 @@ def follow_document(doctype, doc_name, user):
@frappe.whitelist()
def unfollow_document(doctype, doc_name, user):
def unfollow_document(doctype: str, doc_name: str, user: str) -> bool:
doc = frappe.get_all(
"Document Follow",
filters={"ref_doctype": doctype, "ref_docname": doc_name, "user": user},

View file

@ -7,6 +7,7 @@
--folder-icon-background-color: var(--surface-gray-1);
--desktop-modal-radius: 30px;
--desktop-icon-line-height: 115%;
--navbar-height: 52px;
}
[data-theme="dark"]{
--folder-icon-background-color: #2b2b2b;
@ -27,7 +28,7 @@
width: 100%;
padding: 10px 20px 10px 20px;
box-sizing: border-box;
height: 52px;
height: var(--navbar-height);
position: sticky;
top: 0px;
z-index: 1030;
@ -103,7 +104,7 @@
padding: 13px 16px 12px 16px;
position: relative;
}
.desktop-icon.edit-mode .hide-button {
.desktop-icon.desktop-edit-mode .hide-button {
display: flex;
}
.icon-container:has(.app-logo) {
@ -182,6 +183,7 @@
& .modal-content {
top: 120px;
border-radius: var(--desktop-modal-radius);
align-items: center;
}
}
}
@ -195,10 +197,13 @@
width: var(--desktop-modal-width);
height: var(--desktop-modal-height);
padding: 24px 23px !important;
width: fit-content;
& .icons{
gap: 0px 0px;
}
& .icons:has(.desktop-edit-mode){
margin-top: 4px;
gap: 6px 6px;
}
.icon-container{
min-height: var(--desktop-icon-dimension);
}
@ -234,7 +239,7 @@
}
.modal-body .icons{
margin-top: 0px;
place-self: start;
place-self: anchor-center;
& .desktop-icon{
height: 126px;
width: 127px;
@ -337,7 +342,7 @@
left: 108px;
}
.edit-mode{
.desktop-edit-mode{
border: 1px dashed var(--outline-gray-2);
border-radius: 20px;
}
@ -395,7 +400,9 @@
}
.desktop-modal-body {
width: 90vw;
width: calc(100vw - 20px);
padding-left: 0px !important;
padding-right: 0px !important;
> .icons-container {
width: 100%;
overflow: hidden !important;
@ -404,10 +411,8 @@
padding: 0;
> .icons {
position: relative;
right: 6%;
column-gap: 4px;
row-gap: 8px;
column-gap: 6px;
row-gap: 6px;
@media screen and (max-width: 380px) {
--desktop-icon-container: 100px;
@ -463,4 +468,69 @@
[data-theme="dark"] .desktop-edit:hover{
opacity: 0.8;
transition: opacity 0.3s;
}
.desktop-navbar-modal-search:hover{
outline: 1px solid var(--surface-gray-3);
}
.desktop-pane{
position: absolute;
top: var(--navbar-height);
right: 0px;
width: 300px;
border-left: 1px solid var(--border-color);
background-color: rgba(255,255,255, 1);
height: 100vh;
}
.pane-header{
display: flex;
font-size: var(--text-lg);
padding: var(--padding-md);
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
}
.pane-icons-area{
.icons-container {
height: 100%;
margin-top: 0px;
}
.icons{
height: 100%;
overflow: scroll;
}
}
.add-new-icon{
cursor: pointer;
gap: 0px;
justify-content: center;
}
.title-widget{
display: inline-block;
position: relative;
}
.title-input-label{
position: absolute;
top: 0px;
color: var(--neutral-white);
line-height: 22px;
z-index: 1;
pointers-events: none;
width: 100%;
text-align: center;
}
.title-input-wrapper{
position: relative;
display: inline-block;
}
.title-input-wrapper input{
border: 1px solid transparent;
width: 100%;
height: 100%;
background: none;
color: var(--neutral-white);
}

View file

@ -53,7 +53,6 @@
<div class="desktop-avatar">
</div>
</div>
</div>
</header>
<div class="desktop-container">
@ -66,4 +65,5 @@
{{ _("Save") }}
</button>
</div>
<div class="hidden" id="desktop-layout">{{ desktop_layout }}</pre>
</div>

View file

@ -1,6 +1,7 @@
frappe.desktop_utils = {};
frappe.desktop_grids = [];
frappe.desktop_icons_objects = [];
frappe.new_icons = [];
$.extend(frappe.desktop_utils, {
modal: null,
modal_stack: [],
@ -42,6 +43,9 @@ function get_route(desktop_icon) {
let item = {};
if (desktop_icon.link_type == "External" && desktop_icon.link) {
route = window.location.origin + desktop_icon.link;
if (desktop_icon.link.startsWith("http") || desktop_icon.link.startsWith("https")) {
route = desktop_icon.link;
}
} else {
let sidebar = frappe.boot.workspace_sidebar_item[desktop_icon.label.toLowerCase()];
if (desktop_icon.link_type == "Workspace Sidebar" && sidebar) {
@ -87,6 +91,9 @@ function get_route(desktop_icon) {
type: first_link.link_type,
name: first_link.link_to,
tab: first_link.tab,
route_options: {
sidebar: desktop_icon.label,
},
});
}
}
@ -95,19 +102,18 @@ function get_route(desktop_icon) {
return route;
}
function get_desktop_icon_by_label(title, filters) {
function get_desktop_icon_by_label(title, filters, force) {
if (force === undefined) force = false;
let icons = frappe.desktop_icons;
if (frappe.pages["desktop"].desktop_page.edit_mode) {
if (!force && frappe.pages["desktop"].desktop_page.edit_mode) {
icons = frappe.new_desktop_icons;
}
if (!filters) {
return icons.find((f) => f.label === title && f.hidden != 1);
return icons.find((f) => f.label === title);
} else {
return icons.find((f) => {
return (
f.label === title &&
Object.keys(filters).every((key) => f[key] === filters[key]) &&
f.hidden != 1
f.label === title && Object.keys(filters).every((key) => f[key] === filters[key])
);
});
}
@ -119,50 +125,71 @@ function get_desktop_icon_by_idx(idx, parent_icon) {
function save_desktop(icons) {
// saving in localStorage;
localStorage.setItem(`${frappe.session.user}:desktop`, JSON.stringify(icons));
frappe.toast("Desktop Saved");
frappe.pages["desktop"].desktop_page.update();
frappe.pages["desktop"].desktop_page.save_layout(icons, frappe.new_icons);
}
function reset_to_default() {
localStorage.setItem(`${frappe.session.user}:desktop`, null);
frappe.db.delete_doc("Desktop Layout", frappe.session.user).then(() => {
frappe.ui.toolbar.clear_cache();
});
}
frappe.pages["desktop"].on_page_show = function () {
frappe.pages["desktop"].desktop_page.setup();
};
function toggle_icons(icons) {
icons.forEach((i) => {
$(i).parent().parent().show();
});
}
frappe.desktop_utils.get_folder_icons = function (folder_name) {
let icons_in_folder = [];
let icons = frappe.desktop_icons;
if (frappe.pages["desktop"].desktop_page.edit_mode) {
icons = frappe.new_desktop_icons;
}
icons.forEach((icon) => {
if (icon.parent_icon == folder_name) {
icons_in_folder.push(icon.label);
}
});
return icons_in_folder;
};
function add_icons_to_folder(folder_name, items) {
let folder = get_desktop_icon_by_label(folder_name);
items.forEach((item) => {
let icon = get_desktop_icon_by_label(item);
icon.parent_icon = folder.label;
});
frappe.pages["desktop"].desktop_page.update();
}
class DesktopPage {
constructor(page) {
this.page = page;
this.edit_mode = false;
this.prepare();
this.make(page);
this.setup_events();
}
update() {
this.prepare();
this.make();
this.make(this.page);
this.setup();
}
update() {
this.make(this.page);
this.setup();
}
prepare() {
this.apps_icons = [];
this.hidden_icons = [];
this.folders = [];
const icon_map = {};
frappe.desktop_icons = this.get_saved_layout() || frappe.boot.desktop_icons;
let icons = this.edit_mode ? frappe.new_desktop_icons : frappe.desktop_icons;
const all_icons = icons.filter((icon) => {
if (icon.hidden != 1) {
icon.child_icons = [];
icon_map[icon.label] = icon;
if (icon.icon_type == "Folder") {
this.folders.push(icon.label);
}
return true;
} else {
this.hidden_icons.push(icon);
}
return false;
});
@ -183,20 +210,43 @@ class DesktopPage {
}
return JSON.parse(localStorage.getItem(`${frappe.session.user}:desktop`));
}
setup_events() {
this.wrapper.find(".hide-button").on("click", function (event) {
event.preventDefault();
event.stopImmediatePropagation();
let desktop_label = event.currentTarget.parentElement.dataset.id;
let desktop_icon = get_desktop_icon_by_label(desktop_label);
desktop_icon.hidden = 1;
frappe.pages["desktop"].desktop_page.update();
sync_layout() {
const me = this;
let saved_layout = JSON.parse(localStorage.getItem(`${frappe.session.user}:desktop`));
if (!this.data && saved_layout) {
this.save_layout(saved_layout);
} else if (Object.keys(this.data).length != 0) {
frappe.desktop_icons = this.data;
} else {
frappe.desktop_icons = frappe.boot.desktop_icons;
}
}
save_layout(layout, new_icons) {
const me = this;
frappe.call({
method: "frappe.desk.doctype.desktop_layout.desktop_layout.save_layout",
args: {
user: frappe.session.user,
layout: JSON.stringify(layout),
new_icons: JSON.stringify(new_icons),
},
callback: function (r) {
me.data = r.message.layout;
me.make(me.page);
me.setup();
frappe.new_icons = [];
},
});
}
make() {
this.page.page_head.hide();
$(this.page.body).empty();
$(frappe.render_template("desktop")).appendTo(this.page.body);
if (!this.data) {
this.data = JSON.parse($("#desktop-layout").text());
}
this.sync_layout();
this.prepare();
this.wrapper = this.page.body.find(".desktop-container");
this.icon_grid = new DesktopIconGrid({
wrapper: this.wrapper,
@ -206,6 +256,7 @@ class DesktopPage {
col: 3,
},
});
this.setup_context_menu();
if (this.edit_mode) {
this.start_editing_layout();
}
@ -217,9 +268,10 @@ class DesktopPage {
this.setup_navbar();
this.setup_awesomebar();
this.handle_route_change();
this.setup_events();
this.setup_edit_button();
}
setup_edit_button() {
if (this.edit_mode || frappe.is_mobile()) return;
const me = this;
$(".desktop-edit").remove();
this.$desktop_edit_button = $(
@ -233,13 +285,14 @@ class DesktopPage {
me.start_editing_layout();
});
}
setup_editing_mode() {
setup_context_menu() {
const me = this;
let menu_items = [
{
label: "Edit Layout",
icon: "edit",
onClick: function () {
me.$desktop_edit_button.hide();
frappe.new_desktop_icons = JSON.parse(JSON.stringify(frappe.desktop_icons));
me.start_editing_layout();
},
@ -261,23 +314,28 @@ class DesktopPage {
}
stop_editing_layout(action) {
this.edit_mode = false;
$(".desktop-icon").removeClass("edit-mode");
$(".desktop-icon").not(".folder-icon .desktop-icon").removeClass("desktop-edit-mode");
$(".desktop-wrapper").removeAttr("data-mode");
$(".add-new-icon").remove();
this.desktop_pane.hide();
if (action === "cancel") {
frappe.new_desktop_icons = null;
this.update();
return;
}
// submit
save_desktop(frappe.new_desktop_icons);
}
start_editing_layout() {
this.edit_mode = true;
$(".desktop-icon").addClass("edit-mode");
const me = this;
this.desktop_pane = new IconsPane();
$(".desktop-wrapper").attr("data-mode", "Edit");
$(".desktop-edit").remove();
frappe.desktop_icons_objects.forEach((icon) => {
icon.edit_mode = true;
});
frappe.desktop_grids.forEach((desktop_grid) => {
if (!desktop_grid.no_dragging) {
desktop_grid.grids.forEach((grid) => {
@ -285,16 +343,42 @@ class DesktopPage {
});
}
});
frappe.desktop_icons_objects.forEach((icon_object) => {
icon_object.setup_dragging();
this.add_new_icons_to_grid();
if (this.edit_mode) {
this.setup_edit_buttons();
this.desktop_pane.show();
}
}
add_new_icons_to_grid() {
let grid = $($(".desktop-container .icons").get(0));
this.add_new_icon = `<div class="desktop-icon desktop-edit-mode add-new-icon" title="Add New Icon">
${frappe.utils.icon("plus", "lg")}
New Icon
</div>`;
grid.append(this.add_new_icon);
$(".add-new-icon").on("click", function () {
frappe.ui.form.make_quick_entry(
"Desktop Icon",
function (icon) {
frappe.new_desktop_icons.push(icon);
frappe.new_icons.push(icon);
frappe.pages["desktop"].desktop_page.update();
},
"",
"",
null,
true,
true
);
});
if (this.edit_mode) this.setup_edit_buttons();
}
setup_edit_buttons() {
const me = this;
this.$edit_button = $(".edit-mode-buttons");
this.$edit_button.find(".discard").on("click", function () {
me.stop_editing_layout("cancel");
me.delete_new_icons();
$($(".desktop-container .icons").get(0)).find(".add-new-icon").remove();
});
this.$edit_button.find(".save").on("click", function () {
me.stop_editing_layout("submit");
@ -306,6 +390,11 @@ class DesktopPage {
full_height: false,
});
}
delete_new_icons() {
frappe.new_icons = [];
}
setup_avatar() {
$(".desktop-avatar").html(frappe.avatar(frappe.session.user, "avatar-medium"));
let is_dark = document.documentElement.getAttribute("data-theme") === "dark";
@ -403,41 +492,19 @@ class DesktopPage {
me.edit_mode = false;
$(".desktop-icon").removeClass("edit-mode");
$(".desktop-wrapper").removeAttr("data-mode");
$(".desktop-edit").remove();
}
});
}
// setup_icon_search() {
// let all_icons = $(".icon-title");
// let icons_to_show = [];
// $(".desktop-container .icons").append(
// "<div class='no-apps-message hidden'> No apps found </div>"
// );
// $(".desktop-search-wrapper > #navbar-search").on("input", function (e) {
// let search_query = $(e.target).val().toLowerCase();
// console.log(search_query);
// icons_to_show = [];
// all_icons.each(function (index, element) {
// $(element).parent().parent().hide();
// let label = $(element).text().toLowerCase();
// if (label.includes(search_query)) {
// icons_to_show.push(element);
// }
// });
// if (icons_to_show.length == 0) {
// $(".desktop-container .icons").find(".no-apps-message").removeClass("hidden");
// } else {
// $(".desktop-container .icons").find(".no-apps-message").addClass("hidden");
// }
// toggle_icons(icons_to_show);
// });
// }
}
class DesktopIconGrid {
constructor(opts) {
$.extend(this, opts);
this.init();
}
static folder_count = 0;
init() {
this.icons = [];
this.icons_html = [];
// this.page_size = {
@ -452,7 +519,16 @@ class DesktopIconGrid {
this.make();
frappe.desktop_grids.push(this);
}
add_folder() {
DesktopIconGrid.folder_count++;
let icon = frappe.model.get_new_doc("Desktop Icon");
icon.icon_type = "Folder";
icon.label = `Untitled ${DesktopIconGrid.folder_count}`;
icon.idx = 100000;
frappe.new_desktop_icons.push(icon);
frappe.new_icons.push(icon);
return icon;
}
prepare() {
this.total_pages = 1;
this.icons_data = this.icons_data.sort((a, b) => {
@ -467,6 +543,9 @@ class DesktopIconGrid {
make() {
const me = this;
this.icons_container = $(`<div class="icons-container"></div>`).appendTo(this.wrapper);
if (this.compact) {
this.icons_container.css("margin-top", "0px");
}
for (let i = 0; i < this.total_pages; i++) {
let template = `<div class="icons"></div>`;
@ -478,9 +557,6 @@ class DesktopIconGrid {
}
this.grids.push($(template).appendTo(this.icons_container));
this.make_icons(this.icons_data_by_page, this.grids[i]);
// if (!this.no_dragging) {
// this.setup_reordering(this.grids[i]);
// }
}
if (!this.in_folder && this.total_pages > 1) {
this.add_page_indicators();
@ -605,19 +681,31 @@ class DesktopIconGrid {
}
make_icons(icons_data, grid) {
icons_data.forEach((icon) => {
let icon_obj = new DesktopIcon(icon, this.in_folder);
let icon_obj = new DesktopIcon(icon, this.in_folder, this);
let icon_html = icon_obj.get_desktop_icon_html();
this.icons.push(icon_obj);
this.icons_html.push(icon_html);
this.setup_actions_on_icon(icon_obj);
grid.append(icon_html);
});
this.setup_tooltip();
}
setup_actions_on_icon(icon) {
if (this.edit_mode) {
icon.edit_mode = true;
}
if (this.is_pane) {
icon.in_pane = true;
}
}
setup_tooltip() {
$('[data-toggle="tooltip"]').tooltip({
placement: "bottom",
});
}
remove_label_tooltip() {
$('[data-toggle="tooltip"]').tooltip("disable");
}
setup_reordering(grid) {
const me = this;
this.hoverTarget = null;
@ -630,10 +718,22 @@ class DesktopIconGrid {
sort: true, // keep sorting normally
dragoverBubble: true,
group: {
name: "desktop",
name: this.name || "desktop",
put: true,
pull: true,
},
onAdd(evt) {
if (Sortable.get(evt.from).option("group").name == "hidden-icons-grid") {
let icon_name = $(evt.item).attr("data-id");
let icon = get_desktop_icon_by_label(icon_name, {}, true);
icon.index = evt.newIndex;
icon.hidden = 0;
frappe.new_desktop_icons.push(icon);
let hidden_icons = frappe.pages.desktop.desktop_page.hidden_icons;
let added_icon_index = hidden_icons.findIndex((d) => d.label == icon_name);
hidden_icons.splice(added_icon_index, 1);
}
},
onStart(evt) {
frappe.desktop_utils.dragged_item = evt.item;
},
@ -644,9 +744,6 @@ class DesktopIconGrid {
});
dataTransfer.setData("text/plain", JSON.stringify(icon.icon_data)); // `dataTransfer` object of HTML5 DragEvent
},
onMove() {
return frappe.desktop_utils.allow_move || false;
},
onEnd: function (evt) {
if (frappe.desktop_utils.in_folder_creation) return;
if (evt.oldIndex !== evt.newIndex) {
@ -679,6 +776,10 @@ class DesktopIconGrid {
});
}
}
update_grid(icons) {
this.wrapper.empty();
this.init();
}
reorder_icons(reordered_icons, filters) {
reordered_icons.forEach((d, idx) => {
let icon = get_desktop_icon_by_label(d);
@ -694,7 +795,7 @@ class DesktopIconGrid {
}
}
class DesktopIcon {
constructor(icon, in_folder) {
constructor(icon, in_folder, grid_obj) {
this.icon_data = icon;
this.icon_title = this.icon_data.label;
this.icon_subtitle = "";
@ -702,6 +803,8 @@ class DesktopIcon {
this.in_folder = in_folder;
this.icon_data.in_folder = in_folder;
this.link_type = this.icon_data.link_type;
this._edit_mode = false;
this.in_pane = false;
if (this.icon_type != "Folder" && !this.icon_data.sidebar) {
this.icon_route = get_route(this.icon_data);
}
@ -720,12 +823,65 @@ class DesktopIcon {
this.parent_icon = this.icon_data.icon;
this.setup_click();
this.render_folder_thumbnail();
this.grid = grid_obj;
Object.defineProperty(this, "edit_mode", {
get: function () {
return this._edit_mode;
},
set: function (value) {
if (value) {
this.icon.addClass("desktop-edit-mode");
if (this.in_folder) {
this.icon.removeClass("desktop-edit-mode");
}
this.grid.remove_label_tooltip();
this.setup_dragging();
this.setup_edit_menu();
this.setup_hide_button();
this.icon.removeAttr("href");
} else {
this.icon.addClass("desktop-edit-mode");
this.setup_click();
}
this._edit_mode = value;
},
});
Object.defineProperty(this, "in_pane", {
get: function () {
return this._in_pane;
},
set: function (value) {
this._in_pane = value;
if (value) {
this.icon.find(".hide-button").html(frappe.utils.icon("plus"));
this.icon.find(".hide-button").attr("data-mode", "add");
this.setup_hide_button();
} else {
this.icon.find(".hide-button").html(frappe.utils.icon("x"));
this.icon.find(".hide-button").attr("data-mode", "hide");
}
},
});
frappe.desktop_icons_objects.push(this);
}
// this.child_icons = this.get_desktop_icon(this.icon_title).child_icons;
// this.child_icons_data = this.get_child_icons_data();
}
setup_hide_button() {
this.icon.find(".hide-button").on("click", function (event) {
event.preventDefault();
event.stopImmediatePropagation();
let desktop_label = event.currentTarget.parentElement.dataset.id;
let desktop_icon = get_desktop_icon_by_label(desktop_label);
if (event.target.parentElement.dataset.mode == "hide") {
desktop_icon.hidden = 1;
} else {
desktop_icon.hidden = 0;
}
frappe.pages["desktop"].desktop_page.update();
});
}
validate_icon() {
// validate if my workspaces are empty
if (this.icon_data.label == "My Workspaces") {
@ -735,9 +891,6 @@ class DesktopIcon {
if (this.icon_type == "Folder") {
if (this.icon_data.child_icons.length == 0) return false;
}
if (this.icon_type == "Link" && !this.icon_route) {
return false;
}
return true;
}
get_child_icons_data() {
@ -746,36 +899,114 @@ class DesktopIcon {
get_desktop_icon_html() {
return this.icon;
}
setup_edit_menu() {
const me = frappe.pages["desktop"].desktop_page;
let icon_data = this.icon_data;
const icon = this;
frappe.ui.create_menu({
parent: this.icon,
right_click: true,
menu_items: [
{
label: "Edit",
icon: "edit",
condition: function () {
return icon_data.standard != 1;
},
onClick: function () {
frappe.ui.form.make_quick_entry(
"Desktop Icon",
function (icon) {
let old_index = frappe.new_desktop_icons.findIndex(
(d_icon) => d_icon.label == icon.label
);
if (old_index !== -1) {
frappe.new_desktop_icons.splice(old_index, 1);
}
frappe.new_desktop_icons.push(icon);
frappe.new_icons.push(icon.name);
frappe.pages["desktop"].desktop_page.update();
},
function (dialog) {
dialog.set_df_property("label", "read_only", 1);
dialog.fields.forEach((field) => {
field.default = icon_data[field.fieldname];
});
dialog.script_manager.trigger("refresh");
},
icon_data,
null
);
},
},
{
label: "Create Folder",
icon: "folder",
onClick: function () {
let folder = me.grid.add_folder();
add_icons_to_folder(folder.label, [icon_data.label]);
},
},
{
label: "Add To Folder",
icon: "folder-open",
condition: function () {
return me.folders.length > 0;
},
items: me.folders.map((name) => {
return {
label: name,
onClick: function () {
add_icons_to_folder(this.label, [icon_data.label]);
},
};
}),
},
],
});
}
setup_click() {
const me = this;
if (this.child_icons?.length && (this.icon_type == "App" || this.icon_type == "Folder")) {
$(this.icon).on("click", () => {
let modal = frappe.desktop_utils.create_desktop_modal(me);
modal.setup(me.icon_title, me.child_icons, 4);
let $title = modal.modal.find(".modal-title");
let title = new InlineEditor($title, this.icon_data.label, function (
old_value,
new_value
) {
let icon = get_desktop_icon_by_label(old_value);
let folder_icons = frappe.desktop_utils.get_folder_icons(old_value);
if (icon) {
icon.label = new_value;
}
add_icons_to_folder(new_value, folder_icons);
frappe.pages["desktop"].desktop_page.update();
});
modal.show();
});
if (this.icon_type == "App") {
$($(this.icon_caption_area).children()[1]).html(
`${this.child_icons.length} Workspaces`
);
let content = `${this.child_icons.length} Workspaces`;
$($(this.icon_caption_area).children()[1]).html(__(content));
}
} else {
this.icon.attr("href", this.icon_route);
}
if (this.icon_data.sidebar) {
const me = this;
this.icon.on("click", function () {
if (me.icon_data.sidebar == "My Workspaces") {
let sidebar_name = me.icon_data.sidebar.toLowerCase();
if (frappe.boot.workspace_sidebar_item[sidebar_name].items.length == 0) {
frappe.toast("No Private Workspaces for user");
} else {
let workspace_name =
frappe.boot.workspace_sidebar_item[sidebar_name].items[0]["link_to"];
frappe.set_route("Workspaces", "private", workspace_name);
}
}
});
if (this.icon_route && this.icon_route.startsWith("http")) {
this.icon.attr("target", "_blank");
}
if (this.icon_route) {
this.icon.attr("href", this.icon_route);
} else {
this.icon.on("click", function (event) {
frappe.msgprint(
__(
"Icon is not correctly configured please check the workspace sidebar to it"
)
);
});
}
}
}
@ -821,51 +1052,6 @@ class DesktopIcon {
}
}
});
this.icon.on("dragstart", function (event) {
frappe.desktop_utils.dragged_item = event.target;
});
this.icon.on("dragover", function (event) {
console.log(event.target);
if (frappe.desktop_utils.dragged_item == event.target.parentElement) return;
if (
event.target == frappe.desktop_utils.dragged_item ||
frappe.desktop_utils.dragged_item.contains(event.target)
) {
return;
}
if (event.target.parentElement.classList.contains("icon-container")) {
frappe.desktop_utils.allow_move = false;
frappe.desktop_utils.in_folder_creation = true;
let icon_list = [];
icon_list.push(
get_desktop_icon_by_label(event.target.parentElement.parentElement.dataset.id)
);
icon_list.push(
get_desktop_icon_by_label(frappe.desktop_utils.dragged_item.dataset.id)
);
let icon = {
label: "Untitled Folder",
icon_type: "Folder",
child_icons: icon_list,
};
let modal = frappe.desktop_utils.create_desktop_modal(icon);
modal.setup(icon.label, icon_list, 4);
$(event.target.parentElement).addClass("folder-icon");
$(event.target.parentElement).empty();
modal.show();
frappe.boot.desktop_icons.push(icon);
icon_list.forEach((icon) => {
let desktop_icon = frappe.utils.get_desktop_icon_by_label(icon.label);
desktop_icon.parent_icon = "Untitled Folder";
frappe.new_desktop_icons.splice(frappe.boot.desktop_icons.indexOf(icon), 1);
frappe.new_desktop_icons.push(desktop_icon);
});
} else {
frappe.desktop_utils.allow_move = true;
}
});
}
}
@ -876,6 +1062,10 @@ class DesktopModal {
setup(icon_title, child_icons_data, grid_row_size) {
const me = this;
this.make_modal(icon_title);
// Check if we're in edit mode
const is_edit_mode = frappe.pages["desktop"].desktop_page.edit_mode;
this.child_icon_grid = new DesktopIconGrid({
wrapper: this.$child_icons_wrapper,
icons_data: child_icons_data,
@ -883,8 +1073,16 @@ class DesktopModal {
in_folder: false,
in_modal: true,
parent_icon: this.parent_icon_obj,
edit_mode: is_edit_mode, // Pass edit mode state
});
// If in edit mode, setup reordering for the modal icons
if (is_edit_mode) {
this.child_icon_grid.grids.forEach((grid) => {
this.child_icon_grid.setup_reordering(grid);
});
}
this.modal.on("hidden.bs.modal", function () {
me.modal.remove();
frappe.desktop_utils.modal = null;
@ -934,3 +1132,93 @@ class DesktopModal {
this.modal.modal("hide");
}
}
class IconsPane {
constructor() {
this.wrapper = $($(".desktop-container .icons-container").get(0));
}
show() {
this.wrapper.removeClass("hidden");
if (this.grid) {
this.grid.icons_data = frappe.pages.desktop.desktop_page.hidden_icons;
this.grid.update_grid();
return;
}
this.wrapper.append(
"<span style='margin-top: 10px; margin-bottom: 20px'>Removed Icons</span>"
);
this.grid = new DesktopIconGrid({
name: "hidden-icons-grid",
wrapper: this.wrapper,
icons_data: frappe.pages.desktop.desktop_page.hidden_icons,
row_size: 6,
edit_mode: true,
compact: true,
is_pane: true,
});
this.setup();
}
hide() {
this.wrapper.addClass("hidden");
}
setup() {
this.setup_close_button();
}
setup_close_button() {
const me = this;
this.wrapper.find(".close-button").on("click", function () {
me.hide();
});
}
}
class InlineEditor {
constructor(container, initialValue = "", onRename = () => {}) {
this.container = container;
this.initialValue = initialValue;
this.onRename = onRename;
this.render();
this.bindEvents();
}
render() {
this.container.html(`
<div class="title-widget">
<div class="title-input-label">
<span>${__(this.initialValue)}</span>
</div>
<div class="title-input-wrapper">
<input class="title-input">
</div>
</div>
`);
this.input = this.container.find(".title-input");
this.label = this.container.find(".title-input-label");
}
bindEvents() {
this.container.on("click", () => {
if (frappe.pages["desktop"].desktop_page.edit_mode) {
this.label.css("visibility", "hidden");
this.input.focus().select();
}
});
this.input.on("keydown", (event) => {
if (event.key === "Enter") {
const newValue = this.input.val().trim();
this.input.css("display", "none");
this.label.css("visibility", "visible");
this.label.find("span").text(newValue);
this.onRename(this.initialValue, newValue, this);
}
});
this.input.on("blur", () => {
this.label.css("visibility", "visible");
});
}
}

View file

@ -13,8 +13,9 @@ def get_context(context):
if not brand_logo:
brand_logo = frappe.get_hooks("app_logo_url", app_name="frappe")[0]
context.brand_logo = brand_logo
context.desktop_icons = get_desktop_icons()
context.current_user = frappe.session.user
# check if system is mac or not
context.is_mac = sys.platform == "darwin"
try:
context.desktop_layout = frappe.get_doc("Desktop Layout", frappe.session.user).layout or {}
except frappe.DoesNotExistError:
frappe.clear_last_message()
context.desktop_layout = {}
return context

View file

@ -395,7 +395,7 @@ def _export_query(form_params, csv_params, populate_response=True):
if file_format_type == "CSV":
content = get_csv_bytes(
[[handle_html(frappe.as_unicode(v)) if isinstance(v, str) else v for v in r] for r in xlsx_data],
[[handle_html(v) if isinstance(v, str) else v for v in r] for r in xlsx_data],
csv_params,
)
file_extension = "csv"

View file

@ -475,7 +475,7 @@ def _export_query(form_params, csv_params, populate_response=True):
if file_format_type == "CSV":
file_extension = "csv"
content = get_csv_bytes(
[[handle_html(frappe.as_unicode(v)) if isinstance(v, str) else v for v in r] for r in data],
[[handle_html(v) if isinstance(v, str) else v for v in r] for r in data],
csv_params,
)
elif file_format_type == "Excel":

View file

@ -109,31 +109,6 @@ def search_widget(
if filters is None:
filters = {}
are_filters_dict = isinstance(filters, dict)
include_disabled = False
if not query and are_filters_dict:
if "include_disabled" in filters:
if filters["include_disabled"] == 1:
include_disabled = True
filters.pop("include_disabled")
filters = [make_filter_tuple(doctype, key, value) for key, value in filters.items()]
are_filters_dict = False
if for_link_validation:
if are_filters_dict:
# we add filter if possible, otherwise rely on txt
if "name" not in filters:
filters["name"] = txt
else:
filters.append([doctype, "name", "=", txt])
as_dict = False
# for custom queries that don't respect filters but respect limit (rare)
# or for when we have to rely on txt
# we want to match "A" with "A" only and not "A1", "BA" etc.
page_length = PAGE_LENGTH_FOR_LINK_VALIDATION
if query: # Query = custom search query i.e. python function
try:
is_whitelisted(frappe.get_attr(query))
@ -163,6 +138,19 @@ def search_widget(
return []
meta = frappe.get_meta(doctype)
include_disabled = False
if isinstance(filters, dict):
if "include_disabled" in filters:
if filters["include_disabled"] == 1:
include_disabled = True
filters.pop("include_disabled")
filters = [make_filter_tuple(doctype, key, value) for key, value in filters.items()]
if for_link_validation:
filters.append([doctype, "name", "=", txt])
or_filters = []
# build from doctype
@ -364,8 +352,8 @@ def build_for_autosuggest(res: list[tuple], doctype: str) -> list[LinkSearchResu
results.append(autosuggest_row)
else:
for item in res:
value = _(item[0]) if meta.translated_doctype else item[0]
results.append({"value": item[0], "description": to_string(item[1:]), "label": value})
label = _(item[0]) if meta.translated_doctype else item[0]
results.append({"value": item[0], "description": to_string(item[1:]), "label": label})
return results
@ -405,7 +393,7 @@ def get_names_for_mentions(search_term):
def get_users_for_mentions():
return frappe.get_all(
"User",
fields=["name as id", "full_name as value"],
fields=["name as id", "full_name as value", "email"],
filters={
"name": ["not in", ("Administrator", "Guest")],
"allowed_in_mentions": True,

View file

@ -132,7 +132,7 @@ class TestEmailAccount(IntegrationTestCase):
TestEmailAccount.mocked_email_receive(email_account, messages)
comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"})
self.assertTrue("From: &quot;Microsoft Outlook&quot; &lt;test_sender@example.com&gt;" in comm.content)
self.assertTrue('From: "Microsoft Outlook" &lt;test_sender@example.com&gt;' in comm.content)
self.assertTrue(
"This is an e-mail message sent automatically by Microsoft Outlook while" in comm.content
)
@ -153,7 +153,7 @@ class TestEmailAccount(IntegrationTestCase):
TestEmailAccount.mocked_email_receive(email_account, messages)
comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"})
self.assertTrue("From: &quot;Microsoft Outlook&quot; &lt;test_sender@example.com&gt;" in comm.content)
self.assertTrue('From: "Microsoft Outlook" &lt;test_sender@example.com&gt;' in comm.content)
self.assertTrue(
"This is an e-mail message sent automatically by Microsoft Outlook while" in comm.content
)

View file

@ -8,7 +8,7 @@ app_publisher = "Frappe Technologies"
app_description = "Full stack web framework with Python, Javascript, MariaDB, Redis, Node"
app_license = "MIT"
app_logo_url = "/assets/frappe/images/frappe-framework-logo.svg"
develop_version = "15.x.x-develop"
develop_version = "17.x.x-develop"
app_home = "/app/build"
app_email = "developers@frappe.io"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1404,7 +1404,10 @@ class BaseDocument:
):
currency = frappe.db.get_value("Currency", currency_value, cache=True)
val = self.get(fieldname)
if fieldname and (prop := getattr(type(self), fieldname, None)) and is_a_property(prop):
val = getattr(self, fieldname)
else:
val = self.get(fieldname)
if translated:
val = _(val)

View file

@ -240,9 +240,6 @@ def map_fetch_fields(target_doc, df, no_copy_fields):
# options should be like "link_fieldname.fieldname_in_liked_doc"
for fetch_df in target_doc.meta.get("fields", {"fetch_from": f"^{df.fieldname}."}):
if not (fetch_df.fieldtype == "Read Only" or fetch_df.read_only):
continue
if (
not target_doc.get(fetch_df.fieldname) or fetch_df.fieldtype == "Read Only"
) and fetch_df.fieldname not in no_copy_fields:

View file

@ -6,10 +6,8 @@ import datetime
import re
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Optional
from uuid import UUID
import uuid_utils
from typing import TYPE_CHECKING
from uuid import UUID, uuid7
import frappe
from frappe import _
@ -166,8 +164,8 @@ def set_new_name(doc):
if meta.autoname == "UUID":
if not doc.name:
doc.name = str(uuid_utils.uuid7())
elif isinstance(doc.name, UUID | uuid_utils.UUID):
doc.name = str(uuid7())
elif isinstance(doc.name, UUID):
doc.name = str(doc.name)
elif isinstance(doc.name, str): # validate
try:

View file

@ -201,13 +201,14 @@ def remove_orphan_doctypes():
def remove_orphan_entities():
entites = ["Workspace", "Dashboard", "Page", "Report"]
app_level_entities = ["Workspace Sidebar"]
app_level_entities = ["Workspace Sidebar", "Desktop Icon"]
entity_filter_map = {
"Workspace": {"public": 1},
"Page": {"standard": "Yes"},
"Report": {"is_standard": "Yes"},
"Dashboard": {"is_standard": True},
"Workspace Sidebar": {"standard": True},
"Desktop Icon": {"standard": True},
}
entity_file_map = create_entity_file_map(entites)

View file

@ -40,7 +40,6 @@ def update_user_settings(doctype, user_settings, for_update=False):
current = {}
current.update(user_settings)
frappe.cache.hset("_user_settings", f"{doctype}::{frappe.session.user}", json.dumps(current))

View file

@ -5,7 +5,6 @@ import re
from http import cookies
from urllib.parse import unquote, urljoin, urlparse
import jwt
from oauthlib.openid import RequestValidator
import frappe
@ -302,6 +301,8 @@ class OAuthWebRequestValidator(RequestValidator):
# OpenID Connect
def finalize_id_token(self, id_token, token, token_handler, request):
import jwt
# Check whether frappe server URL is set
id_token_header = {"typ": "jwt", "alg": "HS256"}
@ -437,6 +438,8 @@ class OAuthWebRequestValidator(RequestValidator):
- OpenIDConnectImplicit
- OpenIDConnectHybrid
"""
import jwt
if id_token_hint:
try:
user = None

View file

@ -239,7 +239,6 @@ frappe.patches.v15_0.migrate_session_data
frappe.custom.doctype.property_setter.patches.remove_invalid_fetch_from_expressions
frappe.patches.v16_0.switch_default_sort_order
frappe.integrations.doctype.oauth_client.patches.set_default_allowed_role_in_oauth_client
execute:frappe.db.set_single_value("Workspace Settings", "workspace_setup_completed", 1)
frappe.patches.v16_0.add_app_launcher_in_navbar_settings
frappe.desk.doctype.workspace.patches.update_app
frappe.patches.v16_0.move_role_desk_settings_to_user
@ -256,3 +255,4 @@ frappe.patches.v16_0.change_link_type_to_workspace_sidebar
frappe.patches.v16_0.add_standard_field_in_workspace_sidebar
execute:frappe.db.set_single_value("Desktop Settings", "icon_style", "Solid")
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Productivity")
frappe.patches.v16_0.unset_standard_field_for_auto_generated_icons

View file

@ -0,0 +1,18 @@
import frappe
from frappe.model.sync import check_if_record_exists
def execute():
for icon in frappe.get_all("Desktop Icon"):
icon_doc = frappe.get_doc("Desktop Icon", icon.name)
if (icon_doc.standard and icon_doc.app) and not check_if_record_exists(
"app",
frappe.get_app_path(icon_doc.app),
"Desktop Icon",
icon_doc.name,
):
try:
icon_doc.standard = 0
icon_doc.save()
except Exception as e:
print("Error in unsetting standard field", e)

View file

@ -704,6 +704,40 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder {
update_column_count_message();
});
// Toggle all checkboxes in column selector
const toggle_all_checkboxes = function (should_check, should_clear_value) {
// Scope to column selector list checkboxes only
$body
.find(".column-selector-list input[type='checkbox'][data-fieldname]")
.each(function () {
const $checkbox = $(this);
const is_checked = $checkbox.prop("checked");
// Only process checkboxes that need to be changed
if ((should_check && !is_checked) || (!should_check && is_checked)) {
$checkbox.prop("checked", should_check);
const fieldname = $checkbox.attr("data-fieldname");
const input = get_width_input(fieldname);
input.prop("disabled", !should_check);
if (should_clear_value) {
input.val("");
}
}
});
update_column_count_message();
};
// Select All functionality
$body.on("click", ".select-all-btn", function () {
toggle_all_checkboxes(true, false);
});
// Unselect All functionality
$body.on("click", ".unselect-all-btn", function () {
toggle_all_checkboxes(false, true);
});
d.show();
return false;

View file

@ -3,6 +3,12 @@
<p class="help-message alert alert-warning">
{{ __("Some columns might get cut off when printing to PDF. Try to keep number of columns under 10.") }}
</p>
<div class="row" style="margin-bottom: 15px;">
<div class="col-sm-12">
<button class="btn btn-xs btn-default select-all-btn" type="button">{{ __("Select All") }}</button>
<button class="btn btn-xs btn-default unselect-all-btn" type="button" style="margin-left: 5px;">{{ __("Unselect All") }}</button>
</div>
</div>
<div class="row">
<div class="col-sm-6"><p class="bold">{{ __("Column") }}</p></div>
<div class="col-sm-6 text-right"><p class="bold">{{ __("Width") }}</p></div>

View file

@ -47,6 +47,11 @@ input {
cursor: pointer;
}
label {
display: flex;
align-items: center;
}
label .checkbox {
display: flex;
align-items: center;

View file

@ -636,10 +636,13 @@ function upload_file(file, i) {
: __("File upload failed.");
} else {
file.failed = true;
let detail =
xhr.statusText ||
__("Server error during upload. The file might be corrupted.");
file.error_message =
xhr.status === 0
? __("XMLHttpRequest Error")
: `${xhr.status} : ${xhr.statusText}`;
: `${xhr.status} : ${detail}`;
let error = null;
try {

View file

@ -55,6 +55,18 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control
// like links, currencies, HTMLs etc.
this.disp_area = this.$wrapper.find(".control-value").get(0);
}
this.setup_shortcut();
}
setup_shortcut() {
$(this.input_area).on("keydown", function (event) {
if (event.originalEvent.ctrlKey || event.originalEvent.metaKey) {
if (event.originalEvent.key === "k" || event.originalEvent.key === "K") {
$("#navbar-modal-search").click();
event.preventDefault();
return false;
}
}
});
}
set_max_width() {
if (this.constructor.horizontal) {
@ -165,7 +177,16 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control
let doc = this.doc || (this.frm && this.frm.doc);
let display_value = frappe.format(value, this.df, { no_icon: true, inline: true }, doc);
// This is used to display formatted output AND showing values in read only fields
this.disp_area && $(this.disp_area).html(display_value);
if (this.disp_area) {
$(this.disp_area).html(display_value);
// Apply alignment only for supported fields
if (
this.df.alignment &&
["Data", "Int", "Float", "Currency", "Percent"].includes(this.df.fieldtype)
) {
$(this.disp_area).css("text-align", this.df.alignment.toLowerCase());
}
}
}
set_label(label) {
if (label) this.df.label = label;

Some files were not shown because too many files have changed in this diff Show more