Merge branch 'develop' into po-translation

This commit is contained in:
barredterra 2023-11-06 19:40:25 +01:00
commit 67404e0cd0
110 changed files with 1795 additions and 702 deletions

View file

@ -6,7 +6,8 @@
"allow_tests": true,
"db_type": "mariadb",
"auto_email_id": "test@example.com",
"mail_server": "smtp.example.com",
"mail_server": "localhost",
"mail_port": 2525,
"mail_login": "test@example.com",
"mail_password": "test",
"admin_password": "admin",

View file

@ -6,7 +6,8 @@
"db_type": "postgres",
"allow_tests": true,
"auto_email_id": "test@example.com",
"mail_server": "smtp.example.com",
"mail_server": "localhost",
"mail_port": 2525,
"mail_login": "test@example.com",
"mail_password": "test",
"admin_password": "admin",

View file

@ -72,6 +72,12 @@ jobs:
ports:
- 5432:5432
smtp_server:
image: rnwood/smtp4dev
ports:
- 2525:25
- 3000:80
steps:
- name: Clone
uses: actions/checkout@v4

View file

@ -56,12 +56,17 @@ context("Form Builder", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
let first_column = ".tab-content.active .section-columns-container:first .column:first";
cy.get(".fields-container .field[title='Table']").drag(first_field, {
target: { x: 100, y: 10 },
});
let last_field = first_column + " .field:last";
let add_new_field_btn = first_column + " .add-new-field-btn button";
// add new field
cy.get(add_new_field_btn).click();
// type table and press enter
cy.get(".combo-box-options:visible .search-box > input").type("table{enter}");
// save
cy.click_doc_primary_button("Save");
@ -70,7 +75,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "Options is required");
cy.hide_dialog();
cy.get(first_field).click({ force: true });
cy.get(last_field).click({ force: true });
cy.get(".sidebar-container .frappe-control[data-fieldname='options'] input")
.click()
@ -78,13 +83,10 @@ context("Form Builder", () => {
cy.get("@input").clear({ force: true }).type("Web Form Field", { delay: 200 });
cy.wait("@search_link");
cy.get(first_field).click({ force: true });
cy.get(last_field).click({ force: true });
cy.get(first_field)
.find(".table-controls .table-column")
.contains("Field")
.should("exist");
cy.get(first_field)
cy.get(last_field).find(".table-controls .table-column").contains("Field").should("exist");
cy.get(last_field)
.find(".table-controls .table-column")
.contains("Fieldtype")
.should("exist");
@ -98,7 +100,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "In List View");
cy.hide_dialog();
cy.get(first_field).click({ force: true });
cy.get(last_field).click({ force: true });
cy.get(".sidebar-container .field label .label-area").contains("In List View").click();
// validate In Global Search
@ -188,7 +190,7 @@ context("Form Builder", () => {
// add new column
cy.get(first_section).find(".column:first").click(15, 10);
cy.get(first_section).find(".column:first .column-actions button:first").click();
cy.get(first_section).find(".column").should("have.length", 3);
cy.get(first_section).find(".column").should("have.length", 2);
});
it("Remove Tab/Section/Column", () => {
@ -197,7 +199,7 @@ context("Form Builder", () => {
// remove column
cy.get(first_section).find(".column:first").click(15, 10);
cy.get(first_section).find(".column:first .column-actions button:last").click();
cy.get(first_section).find(".column").should("have.length", 2);
cy.get(first_section).find(".column").should("have.length", 1);
// remove section
cy.get(first_section).click(15, 10);
@ -205,7 +207,7 @@ context("Form Builder", () => {
cy.get(".tab-content.active .form-section-container").should("have.length", 1);
// remove tab
cy.get(".tab-header").realHover().find(".tab-actions .remove-tab-btn").click();
cy.get(".tab-header .tab:last").realHover().find(".remove-tab-btn").click();
cy.get(".tab-header .tabs .tab").should("have.length", 2);
});
@ -231,14 +233,19 @@ context("Form Builder", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
let first_column = ".tab-content.active .section-columns-container:first .column:first";
cy.get(".fields-container .field[title='Data']").drag(first_field, {
target: { x: 100, y: 10 },
});
let last_field = first_column + " .field:last";
cy.get(first_field).click();
let add_new_field_btn = first_column + " .add-new-field-btn button";
// add new field
cy.get(add_new_field_btn).click();
// type data and press enter
cy.get(".combo-box-options:visible .search-box > input").type("data{enter}");
cy.get(last_field).click();
// validate duplicate name
cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input")
@ -251,7 +258,7 @@ context("Form Builder", () => {
cy.click_doc_primary_button("Save");
cy.get_open_dialog().find(".msgprint").should("contain", "appears multiple times");
cy.hide_dialog();
cy.get(first_field).click();
cy.get(last_field).click();
cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input").clear({
force: true,
});

View file

@ -1,11 +1,11 @@
let path = require("path");
let { get_app_path, app_list } = require("./utils");
let node_modules_path = path.resolve(get_app_path("frappe"), "..", "node_modules");
let app_paths = app_list.map(get_app_path).map((app_path) => path.resolve(app_path, ".."));
let node_modules_path = app_paths.map((app_path) => path.resolve(app_path, "node_modules"));
module.exports = {
includePaths: [node_modules_path, ...app_paths],
includePaths: [...node_modules_path, ...app_paths],
quietDeps: true,
importer: function (url) {
if (url.startsWith("~")) {

View file

@ -136,16 +136,6 @@ def as_unicode(text: str, encoding: str = "utf-8") -> str:
return str(text)
def get_lang_dict(fortype: str, name: str | None = None) -> dict[str, str]:
"""Returns the translated language dict for the given type and name.
:param fortype: must be one of `doctype`, `page`, `report`, `include`, `jsfile`, `boot`
:param name: name of the document for which assets are to be returned."""
from frappe.translate import get_dict
return get_dict(fortype, name)
def set_user_lang(user: str, user_language: str | None = None) -> None:
"""Guess and set user language for the session. `frappe.local.lang`"""
from frappe.translate import get_user_lang
@ -160,6 +150,7 @@ qb = local("qb")
conf = local("conf")
form = form_dict = local("form_dict")
request = local("request")
job = local("job")
response = local("response")
session = local("session")
user = local("user")

View file

@ -20,7 +20,7 @@ from frappe.twofactor import (
)
from frappe.utils import cint, date_diff, datetime, get_datetime, today
from frappe.utils.deprecations import deprecation_warning
from frappe.utils.password import check_password
from frappe.utils.password import check_password, get_decrypted_password
from frappe.website.utils import get_home_page
SAFE_HTTP_METHODS = frozenset(("GET", "HEAD", "OPTIONS"))
@ -574,6 +574,11 @@ def validate_auth():
validate_oauth(authorization_header)
validate_auth_via_api_keys(authorization_header)
# If login via bearer, basic or keypair didn't work then authentication failed and we
# should terminate here.
if frappe.session.user in ("", "Guest"):
raise frappe.AuthenticationError
validate_auth_via_hooks()
@ -588,6 +593,9 @@ def validate_oauth(authorization_header):
from frappe.integrations.oauth2 import get_oauth_server
from frappe.oauth import get_url_delimiter
if authorization_header[0].lower() != "bearer":
return
form_dict = frappe.local.form_dict
token = authorization_header[1]
req = frappe.request
@ -613,7 +621,7 @@ def validate_oauth(authorization_header):
frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
frappe.local.form_dict = form_dict
except AttributeError:
pass
raise frappe.AuthenticationError
def validate_auth_via_api_keys(authorization_header):
@ -639,15 +647,17 @@ def validate_auth_via_api_keys(authorization_header):
frappe.InvalidAuthorizationToken,
)
except (AttributeError, TypeError, ValueError):
pass
raise frappe.AuthenticationError
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
doctype = frappe_authorization_source or "User"
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
if not doc:
raise frappe.AuthenticationError
form_dict = frappe.local.form_dict
doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret")
doc_secret = get_decrypted_password(doctype, doc, fieldname="api_secret")
if api_secret == doc_secret:
if doctype == "User":
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
@ -656,6 +666,8 @@ def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=Non
if frappe.local.login_manager.user in ("", "Guest"):
frappe.set_user(user)
frappe.local.form_dict = form_dict
else:
raise frappe.AuthenticationError
def validate_auth_via_hooks():

View file

@ -28,6 +28,7 @@ def get_list(
doctype,
fields=None,
filters=None,
group_by=None,
order_by=None,
limit_start=None,
limit_page_length=20,
@ -53,6 +54,7 @@ def get_list(
fields=fields,
filters=filters,
or_filters=or_filters,
group_by=group_by,
order_by=order_by,
limit_start=limit_start,
limit_page_length=limit_page_length,

View file

@ -504,7 +504,7 @@ def postgres(context, extra_args):
def _mariadb(extra_args=None):
mariadb = which("mariadb")
mariadb = which("mariadb") or which("mysql")
command = [
mariadb,
"--port",

View file

@ -148,7 +148,7 @@
"icon": "fa fa-map-marker",
"idx": 5,
"links": [],
"modified": "2023-10-11 11:48:26.954934",
"modified": "2023-10-30 05:50:23.912366",
"modified_by": "Administrator",
"module": "Contacts",
"name": "Address",
@ -217,7 +217,6 @@
"write": 1
}
],
"quick_entry": 1,
"search_fields": "country, state",
"sort_field": "modified",
"sort_order": "DESC",

View file

@ -3,6 +3,20 @@
frappe.ui.form.on("Audit Trail", {
refresh(frm) {
let prev_route = frappe.get_prev_route();
if (
prev_route.length > 2 &&
prev_route[0] == "Form" &&
!prev_route.includes("Audit Trail")
) {
frm.set_value("doctype_name", prev_route[1]);
frm.set_value("document", prev_route[2]);
frm.set_value("start_date", "");
frm.set_value("end_date", "");
if (frm.doc.doctype_name && frm.doc.document)
frm.events.get_audit_trail_for_document(frm);
}
frm.page.clear_indicator();
frm.disable_save();
@ -16,17 +30,61 @@ frappe.ui.form.on("Audit Trail", {
};
});
frm.set_query("document", () => {
let filters = {
amended_from: ["!=", ""],
};
if (frm.doc.start_date && frm.doc.end_date)
filters["creation"] = ["between", [frm.doc.start_date, frm.doc.end_date]];
else if (frm.doc.start_date) filters["creation"] = [">=", frm.doc.start_date];
else if (frm.doc.end_date) filters["creation"] = ["<=", frm.doc.end_date];
return {
filters: filters,
};
});
frm.page.set_primary_action("Compare", () => {
frm.call({
doc: frm.doc,
method: "compare_document",
callback: function (r) {
let document_names = r.message[0];
let changed_fields = r.message[1];
frm.events.render_changed_fields(frm, document_names, changed_fields);
frm.events.render_rows_added_or_removed(frm, changed_fields);
},
frm.events.get_audit_trail_for_document(frm);
});
},
start_date(frm) {
if (frm.doc.start_date > frm.doc.end_date) {
frm.doc.end_date = "";
frm.refresh_fields();
}
frappe.db
.get_value(frm.doc.doctype_name, frm.doc.document, "creation")
.then((creation) => {
if (frappe.datetime.obj_to_str(creation) < frm.doc.start_date) {
frm.doc.document = "";
frm.refresh_fields();
}
});
},
end_date(frm) {
frappe.db
.get_value(frm.doc.doctype_name, frm.doc.document, "creation")
.then((creation) => {
if (frappe.datetime.obj_to_str(creation) > frm.doc.end_date) {
frm.doc.document = "";
frm.refresh_fields();
}
});
},
get_audit_trail_for_document(frm) {
frm.call({
doc: frm.doc,
method: "compare_document",
callback: function (r) {
let document_names = r.message[0];
let changed_fields = r.message[1];
frm.events.render_changed_fields(frm, document_names, changed_fields);
frm.events.render_rows_added_or_removed(frm, changed_fields);
},
});
},

View file

@ -9,6 +9,10 @@
"doctype_name",
"column_break_peck",
"document",
"section_break_dfrx",
"start_date",
"column_break_ytzm",
"end_date",
"section_break_gppi",
"version_table",
"rows_added_section",
@ -68,13 +72,34 @@
"fieldtype": "Section Break",
"hidden": 1,
"label": "Rows Removed"
},
{
"fieldname": "start_date",
"fieldtype": "Date",
"label": "Start Date"
},
{
"fieldname": "end_date",
"fieldtype": "Date",
"label": "End Date"
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.start_date || doc.end_date",
"fieldname": "section_break_dfrx",
"fieldtype": "Section Break",
"label": "Date Range"
},
{
"fieldname": "column_break_ytzm",
"fieldtype": "Column Break"
}
],
"hide_toolbar": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2023-10-04 12:45:49.099121",
"modified": "2023-10-31 13:12:41.749483",
"modified_by": "Administrator",
"module": "Core",
"name": "Audit Trail",

View file

@ -7,6 +7,7 @@ import frappe
from frappe import _
from frappe.core.doctype.version.version import get_diff
from frappe.model.document import Document
from frappe.utils import compare
class AuditTrail(Document):
@ -20,20 +21,31 @@ class AuditTrail(Document):
doctype_name: DF.Link
document: DF.DynamicLink
end_date: DF.Date | None
start_date: DF.Date | None
# end: auto-generated types
pass
def validate(self):
self.validate_doctype_name()
self.validate_fields()
self.validate_document()
def validate_doctype_name(self):
if not self.doctype_name:
frappe.throw(_("{} field cannot be empty.").format(frappe.bold("Doctype")))
def validate_fields(self):
fields_dict = {
"DocType": self.doctype_name,
"Document": self.document,
}
for field in fields_dict:
if not fields_dict[field]:
frappe.throw(_("{} field cannot be empty.").format(frappe.bold(field)))
def validate_document(self):
if not self.document:
frappe.throw(_("{} field cannot be empty.").format(frappe.bold("Document")))
if not frappe.db.exists(self.doctype_name, self.document):
frappe.throw(
_("The selected document {0} is not a {1}.").format(
frappe.bold(self.document), frappe.bold(self.doctype_name)
)
)
@frappe.whitelist()
def compare_document(self):
@ -58,11 +70,18 @@ class AuditTrail(Document):
}
def get_amended_documents(self):
start_date = self.get("start_date")
amended_document_names = []
curr_doc = self.document
while curr_doc and len(amended_document_names) < 5:
creation = frappe.db.get_value(self.doctype_name, self.document, "creation")
while (
curr_doc
and len(amended_document_names) < 5
and (start_date is None or compare(creation, ">=", start_date, "Date"))
):
amended_document_names.append(curr_doc)
curr_doc = frappe.db.get_value(self.doctype_name, curr_doc, "amended_from")
creation = frappe.db.get_value(self.doctype_name, curr_doc, "creation")
amended_document_names = amended_document_names[::-1]
return amended_document_names

View file

@ -3,6 +3,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import today
class TestAuditTrail(FrappeTestCase):
@ -129,6 +130,11 @@ def amend_document(amend_from, changed_fields, rows_updated, submit=False):
def create_comparator_doc(doctype_name, document):
comparator = frappe.new_doc("Audit Trail")
comparator.doctype_name = doctype_name
comparator.document = document
args_dict = {
"doctype_name": doctype_name,
"document": document,
"start_date": today(),
"end_date": today(),
}
comparator.update(args_dict)
return comparator

View file

@ -0,0 +1,6 @@
import frappe
def execute():
"""Remove stale docfields from legacy version"""
frappe.db.delete("DocField", {"options": "Data Import", "parent": "Data Import Legacy"})

View file

@ -2,6 +2,12 @@
// MIT License. See license.txt
frappe.ui.form.on("DocType", {
onload: function (frm) {
if (frm.is_new()) {
frappe.listview_settings["DocType"].new_doctype_dialog();
}
},
before_save: function (frm) {
let form_builder = frappe.form_builder;
if (form_builder?.store) {
@ -13,6 +19,7 @@ frappe.ui.form.on("DocType", {
}
}
},
after_save: function (frm) {
if (
frappe.form_builder &&
@ -22,6 +29,7 @@ frappe.ui.form.on("DocType", {
frappe.form_builder.store.fetch();
}
},
refresh: function (frm) {
frm.set_query("role", "permissions", function (doc) {
if (doc.custom && frappe.session.user != "Administrator") {
@ -119,6 +127,20 @@ frappe.ui.form.on("DocType", {
setup_default_views: (frm) => {
frappe.model.set_default_views_for_doctype(frm.doc.name, frm);
},
on_tab_change: (frm) => {
let current_tab = frm.get_active_tab().label;
if (current_tab === "Form") {
frm.footer.wrapper.hide();
frm.form_wrapper.find(".form-message").hide();
frm.form_wrapper.addClass("mb-1");
} else {
frm.footer.wrapper.show();
frm.form_wrapper.find(".form-message").show();
frm.form_wrapper.removeClass("mb-1");
}
},
});
frappe.ui.form.on("DocField", {

View file

@ -8,6 +8,9 @@
"document_type": "Document",
"engine": "InnoDB",
"field_order": [
"form_builder_tab",
"form_builder",
"settings_tab",
"sb0",
"module",
"is_submittable",
@ -32,32 +35,6 @@
"column_break_15",
"description",
"documentation",
"sb2",
"permissions",
"restrict_to_domain",
"read_only",
"in_create",
"actions_section",
"actions",
"links_section",
"links",
"document_states_section",
"states",
"web_view",
"has_web_view",
"allow_guest_to_view",
"index_web_pages_for_search",
"route",
"is_published_field",
"website_search_field",
"advanced",
"engine",
"migration_hash",
"form_builder_tab",
"form_builder",
"fields_section",
"fields",
"settings_tab",
"form_settings_section",
"image_field",
"timeline_field",
@ -92,6 +69,29 @@
"email_append_to",
"sender_field",
"subject_field",
"sb2",
"permissions",
"restrict_to_domain",
"read_only",
"in_create",
"actions_section",
"actions",
"links_section",
"links",
"document_states_section",
"states",
"web_view",
"has_web_view",
"allow_guest_to_view",
"index_web_pages_for_search",
"route",
"is_published_field",
"website_search_field",
"advanced",
"engine",
"migration_hash",
"fields_section",
"fields",
"connections_tab"
],
"fields": [
@ -640,6 +640,7 @@
"label": "Settings"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "form_builder_tab",
"fieldtype": "Tab Break",
"label": "Form"
@ -742,7 +743,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2023-08-29 12:27:06.587523",
"modified": "2023-11-01 16:45:14.960949",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

@ -350,8 +350,10 @@ class DocType(Document):
self.flags.update_fields_to_fetch_queries = []
if set(old_fields_to_fetch) != {df.fieldname for df in new_meta.get_fields_to_fetch()}:
for df in new_meta.get_fields_to_fetch():
new_fields_to_fetch = new_meta.get_fields_to_fetch()
if set(old_fields_to_fetch) != {df.fieldname for df in new_fields_to_fetch}:
for df in new_fields_to_fetch:
if df.fieldname not in old_fields_to_fetch:
link_fieldname, source_fieldname = df.fetch_from.split(".", 1)
link_df = new_meta.get_field(link_fieldname)
@ -868,6 +870,7 @@ class DocType(Document):
"read_only": 1,
"print_hide": 1,
"no_copy": 1,
"search_index": 1,
},
)

View file

@ -0,0 +1,122 @@
frappe.listview_settings["DocType"] = {
primary_action: function () {
this.new_doctype_dialog();
},
new_doctype_dialog() {
let non_developer = frappe.session.user !== "Administrator" || !frappe.boot.developer_mode;
let fields = [
{
label: __("DocType Name"),
fieldname: "name",
fieldtype: "Data",
reqd: 1,
},
{ fieldtype: "Column Break" },
{
label: __("Module"),
fieldname: "module",
fieldtype: "Link",
options: "Module Def",
reqd: 1,
},
{ fieldtype: "Section Break" },
{
label: __("Is Submittable"),
fieldname: "is_submittable",
fieldtype: "Check",
description: __(
"Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended."
),
depends_on: "eval:!doc.istable && !doc.issingle",
},
{
label: __("Is Child Table"),
fieldname: "istable",
fieldtype: "Check",
description: __("Child Tables are shown as a Grid in other DocTypes"),
depends_on: "eval:!doc.is_submittable && !doc.issingle",
},
{
label: __("Editable Grid"),
fieldname: "editable_grid",
fieldtype: "Check",
depends_on: "istable",
default: 1,
},
{
label: __("Is Single"),
fieldname: "issingle",
fieldtype: "Check",
description: __(
"Single Types have only one record no tables associated. Values are stored in tabSingles"
),
depends_on: "eval:!doc.istable && !doc.is_submittable",
},
{
label: "Is Tree",
fieldname: "is_tree",
fieldtype: "Check",
default: "0",
depends_on: "eval:!doc.istable",
description: "Tree structures are implemented using Nested Set",
},
{
label: __("Custom?"),
fieldname: "custom",
fieldtype: "Check",
default: non_developer,
read_only: non_developer,
},
];
if (!non_developer) {
fields.push({
label: "Is Virtual",
fieldname: "is_virtual",
fieldtype: "Check",
default: "0",
});
}
let new_d = new frappe.ui.Dialog({
title: __("Create New DocType"),
fields: fields,
primary_action_label: __("Create & Continue"),
primary_action(values) {
if (!values.istable) values.editable_grid = 0;
frappe.db
.insert({
doctype: "DocType",
...values,
permissions: [
{
create: 1,
delete: 1,
email: 1,
export: 1,
print: 1,
read: 1,
report: 1,
role: "System Manager",
share: 1,
write: 1,
},
],
fields: [{ fieldtype: "Section Break" }],
})
.then((doc) => {
frappe.set_route("Form", "DocType", doc.name);
});
},
secondary_action_label: __("Cancel"),
secondary_action() {
new_d.hide();
if (frappe.get_route()[0] === "Form") {
frappe.set_route("List", "DocType");
}
},
});
new_d.show();
},
};

View file

@ -102,14 +102,16 @@
"icon": "fa fa-file",
"idx": 1,
"links": [],
"modified": "2022-08-03 12:20:54.219236",
"modified": "2023-10-22 22:41:25.568952",
"modified_by": "Administrator",
"module": "Core",
"name": "Page",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,

View file

@ -2,9 +2,10 @@
# License: MIT. See LICENSE
import os
import shutil
import frappe
from frappe import _, conf, safe_decode
from frappe import _, conf, get_module_path, safe_decode
from frappe.build import html_to_js_template
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from frappe.desk.form.meta import get_code_files_via_hooks, get_js
@ -103,7 +104,18 @@ class Page(Document):
return d
def on_trash(self):
if not frappe.conf.developer_mode and not frappe.flags.in_migrate:
frappe.throw(_("Deletion of this document is only permitted in developer mode."))
delete_custom_role("page", self.name)
frappe.db.after_commit(self.delete_folder_with_contents)
def delete_folder_with_contents(self):
module_path = get_module_path(self.module)
dir_path = os.path.join(module_path, "page", frappe.scrub(self.name))
if os.path.exists(dir_path):
shutil.rmtree(dir_path, ignore_errors=True)
def is_permitted(self):
"""Returns true if Has Role is not set or the user is allowed."""
@ -173,11 +185,6 @@ class Page(Document):
# flag for not caching this page
self._dynamic_page = True
if frappe.lang != "en":
from frappe.translate import get_lang_js
self.script += get_lang_js("page", self.name)
for path in get_code_files_via_hooks("page_js", self.name):
js = get_js(path)
if js:

View file

@ -1,5 +1,9 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import os
import unittest
from unittest.mock import patch
import frappe
from frappe.tests.utils import FrappeTestCase
@ -16,3 +20,18 @@ class TestPage(FrappeTestCase):
frappe.NameError,
frappe.get_doc(dict(doctype="Page", page_name="Settings", module="Core")).insert,
)
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
@patch.dict(frappe.conf, {"developer_mode": 1})
def test_trashing(self):
page = frappe.new_doc("Page", page_name=frappe.generate_hash(), module="Core").insert()
page.delete()
frappe.db.commit()
module_path = frappe.get_module_path(page.module)
dir_path = os.path.join(module_path, "page", frappe.scrub(page.name))
self.assertFalse(os.path.exists(dir_path))

View file

@ -2,6 +2,9 @@
// For license information, please see license.txt
frappe.ui.form.on("Recorder", {
onload: function (frm) {
frm.fields_dict.sql_queries.grid.only_sortable();
},
refresh: function (frm) {
frm.disable_save();
frm._sort_order = {};

View file

@ -30,7 +30,7 @@ def validate_receiver_nos(receiver_list):
validated_receiver_list = []
for d in receiver_list:
if not d:
break
continue
# remove invalid character
for x in [" ", "-", "(", ")"]:

View file

@ -217,7 +217,7 @@
"label": "Security"
},
{
"default": "60:00",
"default": "170:00",
"description": "Example: Setting this to 24:00 will log out a user if they are not active for 24:00 hours.",
"fieldname": "session_expiry",
"fieldtype": "Data",

View file

@ -180,6 +180,7 @@
"depends_on": "doc_type",
"fieldname": "fields_section_break",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Fields"
},
{
@ -393,7 +394,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2023-08-29 12:31:55.808848",
"modified": "2023-10-31 02:04:25.955931",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",

View file

@ -57,6 +57,7 @@ class DbManager:
esc = make_esc("$ ")
pv = which("pv")
mariadb_cli = which("mariadb") or which("mysql")
if pv:
pipe = f"{pv} {source} |"
@ -68,7 +69,7 @@ class DbManager:
if pipe:
print("Restoring Database file...")
command = "{pipe} mariadb -u {user} -p{password} -h{host} -P{port} {target} {source}"
command = "{pipe} {mariadb_cli} -u {user} -p{password} -h{host} -P{port} {target} {source}"
command = command.format(
pipe=pipe,
user=esc(user),
@ -77,6 +78,7 @@ class DbManager:
target=esc(target),
source=source,
port=frappe.conf.db_port,
mariadb_cli=mariadb_cli,
)
os.system(command)

View file

@ -1,4 +1,19 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Dashboard Chart Source", {});
frappe.ui.form.on("Dashboard Chart Source", {
refresh: function (frm) {
if (!frm.is_new()) {
frm.add_custom_button(
__("Dashboard Chart"),
function () {
let dashboard_chart = frappe.model.get_new_doc("Dashboard Chart");
dashboard_chart.chart_type = "Custom";
dashboard_chart.source = frm.doc.name;
frappe.set_route("Form", "Dashboard Chart", dashboard_chart.name);
},
__("Create")
);
}
},
});

View file

@ -71,7 +71,7 @@ class SubmittableDocumentTree:
def get_all_children(self):
"""Get all nodes of a tree except the root node (all the nested submitted
documents those are present in referencing tables (dependent tables).
documents those are present in referencing tables dependent tables).
"""
while self.to_be_visited_documents:
next_level_children = defaultdict(list)
@ -101,6 +101,10 @@ class SubmittableDocumentTree:
child_docs = defaultdict(list)
for field in referencing_fields:
if field["fieldname"] == "amended_from":
# perf: amended_from links are always linked to cancelled documents.
continue
links = (
get_referencing_documents(
parent_dt,

View file

@ -1,6 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import io
import os
import frappe
@ -45,9 +44,6 @@ def get_meta(doctype, cached=True) -> "FormMeta":
else:
meta = FormMeta(doctype)
if frappe.local.lang != "en":
meta.set_translations(frappe.local.lang)
return meta
@ -256,18 +252,6 @@ class FormMeta(Meta):
self.set("__form_grid_templates", templates)
def set_translations(self, lang):
from frappe.translate import extract_messages_from_code, make_dict_from_messages
self.set("__messages", frappe.get_lang_dict("doctype", self.name))
# set translations for grid templates
if self.get("__form_grid_templates"):
for content in self.get("__form_grid_templates").values():
messages = extract_messages_from_code(content)
messages = make_dict_from_messages(messages)
self.get("__messages").update(messages)
def load_dashboard(self):
self.set("__dashboard", self.get_dashboard_data())

View file

@ -404,7 +404,7 @@ frappe.setup.slides_settings = [
fieldname: "enable_telemetry",
label: __("Allow sending usage data for improving applications"),
fieldtype: "Check",
default: 1,
default: cint(frappe.telemetry.can_enable()),
depends_on: "eval:frappe.telemetry.can_enable()",
},
{

View file

@ -348,14 +348,17 @@ class EmailAccount(Document):
return frappe.get_doc(cls.DOCTYPE, name)
@classmethod
def find_one_by_filters(cls, **kwargs):
def find_one_by_filters(cls, **kwargs) -> "EmailAccount":
name = frappe.db.get_value(cls.DOCTYPE, kwargs)
return cls.find(name) if name else None
@classmethod
def find_from_config(cls):
config = cls.get_account_details_from_site_config()
return cls.from_record(config) if config else None
if config:
account = cls.from_record(config)
account._from_site_config = True
return account
@classmethod
def create_dummy(cls):
@ -475,9 +478,22 @@ class EmailAccount(Document):
}
def get_smtp_server(self):
"""Get SMTPServer (wrapper around actual smtplib object) for this account.
Implementation Detail: Since SMTPServer is same for each email connection, the same *instance*
is returned every time this function is called from same EmailAccount object.
This enables reusabilty of connection for better performance."""
return self._smtp_server_instance
@functools.cached_property
def _smtp_server_instance(self):
config = self.sendmail_config()
return SMTPServer(**config)
def remove_unpicklable_values(self, state):
super().remove_unpicklable_values(state)
state.pop("_smtp_server_instance", None)
def handle_incoming_connect_error(self, description):
if test_internet():
if self.get_failed_attempts_count() > 2:

View file

@ -31,6 +31,7 @@ from frappe.utils import (
sbool,
split_emails,
)
from frappe.utils.deprecations import deprecated
from frappe.utils.verified_command import get_signed_params
@ -86,7 +87,7 @@ class EmailQueue(Document):
return duplicate
@classmethod
def new(cls, doc_data, ignore_permissions=False):
def new(cls, doc_data, ignore_permissions=False) -> "EmailQueue":
data = doc_data.copy()
if not data.get("recipients"):
return
@ -99,7 +100,7 @@ class EmailQueue(Document):
return doc
@classmethod
def find(cls, name):
def find(cls, name) -> "EmailQueue":
return frappe.get_doc(cls.DOCTYPE, name)
@classmethod
@ -166,14 +167,14 @@ class EmailQueue(Document):
if method := get_hook_method("override_email_send"):
method(self, self.sender, recipient.recipient, message)
else:
if not frappe.flags.in_test:
if not frappe.flags.in_test or frappe.flags.testing_email:
ctx.smtp_server.session.sendmail(
from_addr=self.sender, to_addrs=recipient.recipient, msg=message
)
ctx.update_recipient_status_to_sent(recipient)
if frappe.flags.in_test:
if frappe.flags.in_test and not frappe.flags.testing_email:
frappe.flags.sent_mail = message
return
@ -213,6 +214,7 @@ class EmailQueue(Document):
@task(queue="short")
@deprecated
def send_mail(email_queue_name, smtp_server_instance: SMTPServer = None):
"""This is equivalent to EmailQueue.send.
@ -231,11 +233,7 @@ class SendMailContext:
self.queue_doc: EmailQueue = queue_doc
self.email_account_doc = queue_doc.get_email_account()
self.smtp_server = smtp_server_instance or self.email_account_doc.get_smtp_server()
# if smtp_server_instance is passed, then retain smtp session
# Note: smtp session will have to be manually closed
self.retain_smtp_session = bool(smtp_server_instance)
self.smtp_server: SMTPServer = smtp_server_instance or self.email_account_doc.get_smtp_server()
self.sent_to_atleast_one_recipient = any(
rec.recipient for rec in self.queue_doc.recipients if rec.is_mail_sent()
@ -246,9 +244,6 @@ class SendMailContext:
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if not self.retain_smtp_session:
self.smtp_server.quit()
if exc_type:
update_fields = {"error": "".join(traceback.format_tb(exc_tb))}
if self.queue_doc.retry < get_email_retry_limit():

View file

@ -78,3 +78,20 @@ class TestEmailQueue(FrappeTestCase):
{"subject": f"Failed to send email with subject: {subject}"},
)
self.assertTrue(notification_log)
def test_perf_reusing_smtp_server(self):
"""Ensure that same smtpserver instance is being returned when retrieved multiple times."""
self.assertTrue(frappe.new_doc("Email Queue").get_email_account()._from_site_config)
def get_server(q):
return q.get_email_account().get_smtp_server()
self.assertIs(
get_server(frappe.new_doc("Email Queue")), get_server(frappe.new_doc("Email Queue"))
)
q1 = frappe.new_doc("Email Queue", email_account="_Test Email Account 1")
q2 = frappe.new_doc("Email Queue", email_account="_Test Email Account 1")
self.assertIsNot(get_server(frappe.new_doc("Email Queue")), get_server(q1))
self.assertIs(get_server(q1), get_server(q2))

View file

@ -151,7 +151,7 @@ class TestNewsletter(TestNewsletterMixin, FrappeTestCase):
"Newsletter Email Group", filters={"parent": name}, fields=["email_group"]
)
flush(from_test=True)
flush()
confirmed_unsubscribe(to_unsubscribe, group[0].email_group)
name = self.send_newsletter()

View file

@ -305,7 +305,7 @@ def get_context(context):
def send_sms(self, doc, context):
send_sms(
receiver_list=self.get_receiver_list(doc, context),
msg=frappe.render_template(self.message, context),
msg=frappe.utils.strip_html_tags(frappe.render_template(self.message, context)),
)
def get_list_of_recipients(self, doc, context):

View file

@ -7,6 +7,11 @@ from frappe.utils import cint, cstr, get_url, now_datetime
from frappe.utils.data import getdate
from frappe.utils.verified_command import get_signed_params, verify_request
# After this percent of failures in every batch, entire batch is aborted.
# This usually indicates a systemic failure so we shouldn't keep trying to send emails.
EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_PERCENT = 0.33
EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_COUNT = 10
def get_emails_sent_this_month(email_account=None):
"""Get count of emails sent from a specific email account.
@ -124,35 +129,45 @@ def return_unsubscribed_page(email, doctype, name):
)
def flush(from_test=False):
"""flush email queue, every time: called from scheduler"""
from frappe.email.doctype.email_queue.email_queue import send_mail
def flush():
"""flush email queue, every time: called from scheduler.
This should not be called outside of background jobs.
"""
from frappe.email.doctype.email_queue.email_queue import EmailQueue
# To avoid running jobs inside unit tests
if frappe.are_emails_muted():
msgprint(_("Emails are muted"))
from_test = True
if cint(frappe.db.get_default("suspend_email_queue")) == 1:
return
for row in get_queue():
email_queue_batch = get_queue()
if not email_queue_batch:
return
failed_email_queues = []
for row in email_queue_batch:
try:
frappe.enqueue(
method=send_mail,
email_queue_name=row.name,
now=from_test,
job_id=f"email_queue_sendmail_{row.name}",
queue="short",
deduplicate=True,
)
email_queue: EmailQueue = frappe.get_doc("Email Queue", row.name)
email_queue.send()
except Exception:
frappe.get_doc("Email Queue", row.name).log_error()
failed_email_queues.append(row.name)
if (
len(failed_email_queues) / len(email_queue_batch) > EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_PERCENT
and len(failed_email_queues) > EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_COUNT
):
frappe.throw(_("Email Queue flushing aborted due to too many failures."))
def get_queue():
batch_size = cint(frappe.conf.email_queue_batch_size) or 500
return frappe.db.sql(
"""select
f"""select
name, sender
from
`tabEmail Queue`
@ -160,8 +175,8 @@ def get_queue():
(status='Not Sent' or status='Partially Sent') and
(send_after is null or send_after < %(now)s)
order
by priority desc, creation asc
limit 500""",
by priority desc, retry asc, creation asc
limit {batch_size}""",
{"now": now_datetime()},
as_dict=True,
)

View file

@ -61,6 +61,10 @@ class SMTPServer:
@property
def session(self):
"""Get SMTP session.
We make best effort to revive connection if it's disconnected by checking the connection
health before returning it to user."""
if self.is_session_active():
return self._session
@ -86,14 +90,29 @@ class SMTPServer:
frappe.msgprint(res[1], raise_exception=frappe.OutgoingEmailError)
self._session = _session
self._enqueue_connection_closure()
return self._session
except smtplib.SMTPAuthenticationError:
self.throw_invalid_credentials_exception()
except OSError:
except OSError as e:
# Invalid mail server -- due to refusing connection
frappe.throw(_("Invalid Outgoing Mail Server or Port"), title=_("Incorrect Configuration"))
frappe.throw(
_("Invalid Outgoing Mail Server or Port: {0}").format(str(e)),
title=_("Incorrect Configuration"),
)
def _enqueue_connection_closure(self):
if frappe.request and hasattr(frappe.request, "after_response"):
frappe.request.after_response.add(self.quit)
elif frappe.job:
frappe.job.after_job.add(self.quit)
else:
# Console?
import atexit
atexit.register(self.quit)
def is_session_active(self):
if self._session:

View file

@ -8,6 +8,7 @@ import os
from functools import lru_cache
import frappe
from frappe.utils.deprecations import deprecated
from frappe.utils.momentjs import get_all_timezones
@ -38,29 +39,23 @@ def _get_country_timezone_info():
return {"country_info": get_all(), "all_timezones": get_all_timezones()}
@deprecated
def get_translated_dict():
from babel.dates import Locale, get_timezone, get_timezone_name
return get_translated_countries()
def get_translated_countries():
from babel.dates import Locale
translated_dict = {}
locale = Locale.parse(frappe.local.lang, sep="-")
# timezones
for tz in get_all_timezones():
timezone_name = get_timezone_name(get_timezone(tz), locale=locale, width="short")
if timezone_name:
translated_dict[tz] = timezone_name + " - " + tz
# country names && currencies
for country, info in get_all().items():
country_name = locale.territories.get((info.get("code") or "").upper())
if country_name:
translated_dict[country] = country_name
currency = info.get("currency")
currency_name = locale.currencies.get(currency)
if currency_name:
translated_dict[currency] = currency_name
return translated_dict

View file

@ -15,7 +15,7 @@ from frappe.core.doctype.server_script.server_script_utils import get_server_scr
from frappe.monitor import add_data_to_monitor
from frappe.utils import cint
from frappe.utils.csvutils import build_csv_response
from frappe.utils.deprecations import deprecation_warning
from frappe.utils.deprecations import deprecated, deprecation_warning
from frappe.utils.image import optimize_image
from frappe.utils.response import build_response
@ -347,5 +347,4 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
add_data_to_monitor(methodname=method)
# for backwards compatibility
runserverobj = run_doc_method
runserverobj = deprecated(run_doc_method)

View file

@ -269,11 +269,6 @@ scheduler_events = {
],
}
get_translated_dict = {
("doctype", "System Settings"): "frappe.geo.country_info.get_translated_dict",
("page", "setup-wizard"): "frappe.geo.country_info.get_translated_dict",
}
sounds = [
{"name": "email", "src": "/assets/frappe/sounds/email.mp3", "volume": 0.1},
{"name": "submit", "src": "/assets/frappe/sounds/submit.mp3", "volume": 0.1},

View file

@ -726,7 +726,7 @@ def _guess_mariadb_version() -> tuple[int] | None:
# in non-interactive mode.
# Use db.sql("select version()") instead if connection is available.
with suppress(Exception):
mariadb = which("mariadb")
mariadb = which("mariadb") or which("mysql")
version_output = subprocess.getoutput(f"{mariadb} --version")
version_regex = r"(?P<version>\d+\.\d+\.\d+)-MariaDB"

View file

@ -10,7 +10,7 @@ from frappe import _
from frappe.utils import get_request_session
def make_request(method, url, auth=None, headers=None, data=None, json=None):
def make_request(method, url, auth=None, headers=None, data=None, json=None, params=None):
auth = auth or ""
data = data or {}
headers = headers or {}
@ -18,7 +18,7 @@ def make_request(method, url, auth=None, headers=None, data=None, json=None):
try:
s = get_request_session()
frappe.flags.integration_request = s.request(
method, url, data=data, auth=auth, headers=headers, json=json
method, url, data=data, auth=auth, headers=headers, json=json, params=params
)
frappe.flags.integration_request.raise_for_status()

View file

@ -1,6 +1,8 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import contextlib
import functools
import json
import os
from textwrap import dedent
@ -36,14 +38,18 @@ BENCH_START_MESSAGE = dedent(
def atomic(method):
@functools.wraps(method)
def wrapper(*args, **kwargs):
try:
ret = method(*args, **kwargs)
frappe.db.commit()
return ret
except Exception:
frappe.db.rollback()
raise
except Exception as e:
# database itself can be gone while attempting rollback.
# We should preserve original exception in this case.
with contextlib.suppress(Exception):
frappe.db.rollback()
raise e
return wrapper

View file

@ -782,8 +782,11 @@ class BaseDocument:
else:
values_to_fetch = ["name"] + [_df.fetch_from.split(".")[-1] for _df in fields_to_fetch]
# fallback to dict with field_to_fetch=None if link field value is not found
# (for compatibility, `values` must have same data type)
empty_values = _dict({value: None for value in values_to_fetch})
# don't cache if fetching other values too
values = frappe.db.get_value(doctype, docname, values_to_fetch, as_dict=True)
values = frappe.db.get_value(doctype, docname, values_to_fetch, as_dict=True) or empty_values
if getattr(frappe.get_meta(doctype), "issingle", 0):
values.name = doctype

View file

@ -245,20 +245,28 @@ def check_if_doc_is_linked(doc, method="Delete"):
from frappe.model.rename_doc import get_link_fields
link_fields = get_link_fields(doc.doctype)
ignore_linked_doctypes = doc.get("ignore_linked_doctypes") or []
ignored_doctypes = set()
if method == "Cancel" and (doc_ignore_flags := doc.get("ignore_linked_doctypes")):
ignored_doctypes.update(doc_ignore_flags)
if method == "Delete":
ignored_doctypes.update(frappe.get_hooks("ignore_links_on_delete"))
for lf in link_fields:
link_dt, link_field, issingle = lf["parent"], lf["fieldname"], lf["issingle"]
if link_dt in ignored_doctypes or link_field == "amended_from":
continue
try:
meta = frappe.get_meta(link_dt)
except frappe.DoesNotExistError:
frappe.clear_last_message()
# This mostly happens when app do not remove their customizations, we shouldn't
# prevent link checks from failing in those cases
continue
if issingle:
if frappe.db.get_value(link_dt, None, link_field) == doc.name:
if frappe.db.get_single_value(link_dt, link_field) == doc.name:
raise_link_exists_exception(doc, link_dt, link_dt)
continue
@ -270,12 +278,9 @@ def check_if_doc_is_linked(doc, method="Delete"):
for item in frappe.db.get_values(link_dt, {link_field: doc.name}, fields, as_dict=True):
# available only in child table cases
item_parent = getattr(item, "parent", None)
linked_doctype = item.parenttype if item_parent else link_dt
linked_parent_doctype = item.parenttype if item_parent else link_dt
if linked_doctype in frappe.get_hooks("ignore_links_on_delete") or (
linked_doctype in ignore_linked_doctypes and method == "Cancel"
):
# don't check for communication and todo!
if linked_parent_doctype in ignored_doctypes:
continue
if method != "Delete" and (method != "Cancel" or not DocStatus(item.docstatus).is_submitted()):
@ -288,7 +293,7 @@ def check_if_doc_is_linked(doc, method="Delete"):
continue
else:
reference_docname = item_parent or item.name
raise_link_exists_exception(doc, linked_doctype, reference_docname)
raise_link_exists_exception(doc, linked_parent_doctype, reference_docname)
def check_if_doc_is_dynamically_linked(doc, method="Delete"):

View file

@ -1584,6 +1584,12 @@ class Document(BaseDocument):
DocTags(self.doctype).add(self.name, tag)
def remove_tag(self, tag):
"""Remove a Tag to this document"""
from frappe.desk.doctype.tag.tag import DocTags
DocTags(self.doctype).remove(self.name, tag)
def get_tags(self):
"""Return a list of Tags attached to this document"""
from frappe.desk.doctype.tag.tag import DocTags

View file

@ -10,7 +10,7 @@ import requests
import frappe
from .test_runner import SLOW_TEST_THRESHOLD, make_test_records, set_test_email_config
from .test_runner import SLOW_TEST_THRESHOLD, make_test_records
click_ctx = click.get_current_context(True)
if click_ctx:
@ -38,7 +38,6 @@ class ParallelTestRunner:
frappe.flags.in_test = True
frappe.clear_cache()
frappe.utils.scheduler.disable_scheduler()
set_test_email_config()
self.before_test_setup()
def before_test_setup(self):

View file

@ -230,3 +230,4 @@ execute:frappe.delete_doc_if_exists("Workspace", "Customization")
execute:frappe.db.set_single_value("Document Naming Settings", "default_amend_naming", "Amend Counter")
frappe.patches.v15_0.move_event_cancelled_to_status
frappe.patches.v15_0.set_file_type
frappe.core.doctype.data_import.patches.remove_stale_docfields_from_legacy_version

View file

@ -8,7 +8,7 @@ def execute():
table = frappe.utils.get_table_name(doctype)
# delete the doctype record to avoid broken links
frappe.db.delete("DocType", {"name": doctype})
frappe.delete_doc("DocType", doctype, force=True)
# leaving table in database for manual cleanup
click.secho(

View file

@ -66,7 +66,7 @@ class PrintFormat(Document):
if (
self.standard == "Yes"
and not frappe.local.conf.get("developer_mode")
and not (frappe.flags.in_import or frappe.flags.in_test)
and not (frappe.flags.in_migrate or frappe.flags.in_test)
):
frappe.throw(frappe._("Standard Print Format cannot be updated"))

View file

@ -1,3 +1,5 @@
/* This file is depricated use Inter.scss instead. */
/* Backward compatibility */
@font-face {
font-family: 'Inter V';
font-weight: 100 900;

View file

@ -0,0 +1,164 @@
// TODO instead of making copy of inter.css find a way to import it.
// workaround for css import as it fails for custom website_theme_template
@font-face {
font-family: 'Inter V';
font-weight: 100 900;
font-display: swap;
font-style: normal;
src: url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2-variations'),
url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2');
src: url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2') tech('variations');
}
@font-face {
font-family: 'Inter V';
font-weight: 100 900;
font-display: swap;
font-style: italic;
src: url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2-variations'),
url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2');
src: url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2') tech('variations');
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 100;
src: url("/assets/frappe/css/fonts/inter/inter_thinitalic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_thinitalic.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: normal;
font-weight: 200;
src: url("/assets/frappe/css/fonts/inter/inter_extralight.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_extralight.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 200;
src: url("/assets/frappe/css/fonts/inter/inter_extralightitalic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_extralightitalic.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: normal;
font-weight: 300;
src: url("/assets/frappe/css/fonts/inter/inter_light.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_light.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 300;
src: url("/assets/frappe/css/fonts/inter/inter_lightitalic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_lightitalic.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: normal;
font-weight: 400;
src: url("/assets/frappe/css/fonts/inter/inter_regular.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_regular.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 400;
src: url("/assets/frappe/css/fonts/inter/inter_italic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_italic.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: normal;
font-weight: 500;
src: url("/assets/frappe/css/fonts/inter/inter_medium.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_medium.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 500;
src: url("/assets/frappe/css/fonts/inter/inter_mediumitalic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_mediumitalic.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: normal;
font-weight: 600;
src: url("/assets/frappe/css/fonts/inter/inter_semibold.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_semibold.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 600;
src: url("/assets/frappe/css/fonts/inter/inter_semibolditalic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_semibolditalic.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: normal;
font-weight: 700;
src: url("/assets/frappe/css/fonts/inter/inter_bold.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_bold.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 700;
src: url("/assets/frappe/css/fonts/inter/inter_bolditalic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_bolditalic.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: normal;
font-weight: 800;
src: url("/assets/frappe/css/fonts/inter/inter_extrabold.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_extrabold.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 800;
src: url("/assets/frappe/css/fonts/inter/inter_extrabolditalic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_extrabolditalic.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: normal;
font-weight: 900;
src: url("/assets/frappe/css/fonts/inter/inter_black.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_black.woff") format("woff");
}
@font-face {
font-family: 'Inter';
font-display: swap;
font-style: italic;
font-weight: 900;
src: url("/assets/frappe/css/fonts/inter/inter_blackitalic.woff2") format("woff2"),
url("/assets/frappe/css/fonts/inter/inter_blackitalic.woff") format("woff");
}

View file

@ -12,7 +12,9 @@ let should_render = computed(() => {
});
let container = ref(null);
onClickOutside(container, () => (store.form.selected_field = null));
onClickOutside(container, () => (store.form.selected_field = null), {
ignore: [".combo-box-options"],
});
watch(
() => store.form.layout,
@ -30,22 +32,23 @@ onMounted(() => store.fetch());
class="form-builder-container"
@click="store.form.selected_field = null"
>
<div class="form-controls" @click.stop>
<div class="form-sidebar">
<Sidebar />
</div>
</div>
<div class="form-container">
<div class="form-main" :class="[store.preview ? 'preview' : '']">
<Tabs />
</div>
</div>
<div class="form-controls" @click.stop>
<div class="form-sidebar">
<Sidebar />
</div>
</div>
</div>
<div id="autocomplete-area" />
</template>
<style lang="scss" scoped>
.form-builder-container {
margin: -12px -20px -5px;
margin: -15px -20px -5px;
display: flex;
&.resizing {
@ -59,18 +62,19 @@ onMounted(() => store.fetch());
.form-container {
flex: 1;
background-color: var(--disabled-control-bg);
}
.form-sidebar {
border-right: 1px solid var(--border-color);
border-bottom-left-radius: var(--border-radius);
border-left: 1px solid var(--border-color);
border-bottom-right-radius: var(--border-radius);
}
.form-main {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background-color: var(--card-bg);
margin: 10px;
margin: 5px;
}
.form-sidebar,
@ -171,8 +175,6 @@ onMounted(() => store.fetch());
}
:deep(.preview) {
--field-placeholder-color: var(--fg-bg-color);
.tab,
.column,
.field {
@ -242,6 +244,10 @@ onMounted(() => store.fetch());
margin-bottom: 5px;
}
}
.add-new-field-btn {
display: none;
}
}
}
}
@ -270,7 +276,7 @@ onMounted(() => store.fetch());
}
.form-main > :deep(div:first-child:not(.tab-header)) {
max-height: calc(100vh - 160px);
max-height: calc(100vh - 175px);
}
}
</style>

View file

@ -0,0 +1,139 @@
<template>
<button
ref="add_field_btn_ref"
class="add-field-btn btn btn-xs btn-icon"
:title="tooltip"
@click.stop="toggle_fieldtype_options"
>
<slot>
{{ __("Add field") }}
</slot>
<Teleport to="#autocomplete-area">
<div class="autocomplete" ref="autocomplete_ref">
<div v-show="show">
<Autocomplete
v-model:show="show"
:value="autocomplete_value"
:options="fields"
@change="add_new_field"
placeholder="Search fieldtypes..."
/>
</div>
</div>
</Teleport>
</button>
</template>
<script setup>
import Autocomplete from "./Autocomplete.vue";
import { useStore } from "../store";
import { clone_field } from "../utils";
import { createPopper } from "@popperjs/core";
import { computed, nextTick, ref, watch } from "vue";
import { onClickOutside } from "@vueuse/core";
const store = useStore();
const props = defineProps({
column: {
type: Object,
default: null,
},
field: {
type: Object,
default: null,
},
tooltip: {
type: String,
default: __("Add field"),
},
});
const emit = defineEmits(["update:modelValue"]);
const selected = computed(() => {
let fieldname = props.field ? props.field.df.name : props.column.df.name;
return store.selected(fieldname);
});
const show = ref(false);
const autocomplete_value = ref("");
const fields = computed(() => {
let fields = frappe.model.all_fieldtypes
.filter((df) => {
if (in_list(frappe.model.layout_fields, df)) {
return false;
}
return true;
})
.map((df) => {
let out = { label: df };
return out;
});
return [...fields];
});
const add_field_btn_ref = ref(null);
const autocomplete_ref = ref(null);
const popper = ref(null);
onClickOutside(add_field_btn_ref, () => (show.value = false), { ignore: [autocomplete_ref] });
function setupPopper() {
if (!popper.value) {
popper.value = createPopper(add_field_btn_ref.value, autocomplete_ref.value, {
placement: "bottom-start",
modifiers: [
{
name: "offset",
options: {
offset: [0, 4],
},
},
],
});
} else {
popper.value.update();
}
}
function toggle_fieldtype_options() {
show.value = !show.value;
autocomplete_value.value = "";
nextTick(() => setupPopper());
}
function add_new_field(field) {
fieldtype = field?.label;
if (!fieldtype) return;
let new_field = {
df: store.get_df(fieldtype),
table_columns: [],
};
let cloned_field = clone_field(new_field);
// insert new field after current field
let index = 0;
if (props.field) {
index = props.column.fields.indexOf(props.field);
}
props.column.fields.splice(index + 1, 0, cloned_field);
store.form.selected_field = cloned_field.df;
show.value = false;
}
watch(selected, (val) => {
if (!val) show.value = false;
});
defineExpose({ open: toggle_fieldtype_options });
</script>
<style lang="scss" scoped>
.autocomplete {
z-index: 100;
}
</style>

View file

@ -0,0 +1,159 @@
<template>
<Combobox v-model="selectedValue" nullable>
<ComboboxOptions class="combo-box-options" static>
<div class="search-box">
<ComboboxInput
ref="search"
class="search-input form-control"
type="text"
@change="(e) => (query = e.target.value)"
:value="query"
:placeholder="props.placeholder"
autocomplete="off"
@click.stop
/>
<button class="clear-button btn btn-sm" @click="clear_search">
<div v-html="frappe.utils.icon('close', 'sm')" />
</button>
</div>
<div class="combo-box-items">
<ComboboxOption
as="template"
v-for="(field, i) in filteredOptions"
:key="i"
:value="field"
v-slot="{ active }"
>
<li :class="['combo-box-option', active ? 'active' : '']">
{{ field.label }}
</li>
</ComboboxOption>
</div>
</ComboboxOptions>
</Combobox>
</template>
<script setup>
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from "@headlessui/vue";
import { computed, ref, useAttrs, watch, nextTick } from "vue";
const props = defineProps({
options: {
type: Array,
default: [],
},
placeholder: {
type: String,
default: "",
},
modelValue: {
type: String,
default: "",
},
show: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "update:show", "change"]);
const attrs = useAttrs();
const query = ref(null);
const search = ref(null);
const showOptions = computed({
get() {
return props.show;
},
set(val) {
emit("update:show", val);
},
});
const selectedValue = computed({
get() {
return attrs.value;
},
set(val) {
query.value = "";
if (val) {
showOptions.value = false;
}
emit("change", val);
},
});
const filteredOptions = computed(() => {
return query.value
? props.options.filter((option) => {
return option.label.toLowerCase().includes(query.value.toLowerCase());
})
: props.options;
});
function clear_search() {
selectedValue.value = "";
search.value.el.focus();
}
watch(showOptions, (val) => {
if (val) {
nextTick(() => {
search.value.el.focus();
});
}
});
</script>
<style lang="scss" scoped>
.combo-box {
z-index: 100;
}
.combo-box-options {
width: 100%;
background-color: var(--white);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-2xl);
padding: 0;
}
.combo-box-option {
font-size: small;
text-align: left;
border-radius: var(--border-radius-sm);
padding: 6px 10px;
width: 100%;
&:hover,
&.active {
background-color: var(--bg-light-gray);
}
}
.combo-box-items {
max-height: 200px;
padding: 5px;
padding-top: 0px;
overflow-y: auto;
}
.search-box {
position: relative;
padding: 6px;
.clear-button {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: inline-flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.search-input {
width: 100%;
}
}
</style>

View file

@ -1,15 +1,26 @@
<script setup>
import draggable from "vuedraggable";
import Field from "./Field.vue";
import AddFieldButton from "./AddFieldButton.vue";
import EditableInput from "./EditableInput.vue";
import { ref } from "vue";
import { computed, ref } from "vue";
import { useStore } from "../store";
import { move_children_to_parent, confirm_dialog } from "../utils";
import { move_children_to_parent, confirm_dialog, is_touch_screen_device } from "../utils";
import { useMagicKeys, whenever } from "@vueuse/core";
const props = defineProps(["section", "column"]);
let store = useStore();
const store = useStore();
let hovered = ref(false);
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.not_using_input) {
remove_column();
}
});
const hovered = ref(false);
const selected = computed(() => store.selected(props.column.df.name));
function add_column() {
// insert new column after the current column
@ -29,7 +40,11 @@ function remove_column() {
} else {
confirm_dialog(
__("Delete Column", null, "Title of confirmation dialog"),
__("Are you sure you want to delete the column? All the fields in the column will be moved to the previous column.", null, "Confirmation dialog message"),
__(
"Are you sure you want to delete the column? All the fields in the column will be moved to the previous column.",
null,
"Confirmation dialog message"
),
() => delete_column(),
__("Delete column", null, "Button text"),
() => delete_column(true),
@ -95,21 +110,14 @@ function move_columns_to_section() {
<template>
<div
:class="[
'column',
hovered ? 'hovered' : '',
store.selected(column.df.name) ? 'selected' : ''
]"
:class="['column', selected ? 'selected' : hovered ? 'hovered' : '']"
:title="column.df.fieldname"
@click.stop="store.form.selected_field = column.df"
@mouseover.stop="hovered = true"
@mouseout.stop="hovered = false"
>
<div
:class="[
'column-header',
column.df.label ? 'has-label' : '',
]"
:class="['column-header', column.df.label ? 'has-label' : '']"
:hidden="!column.df.label && store.read_only"
>
<div class="column-label">
@ -120,6 +128,9 @@ function move_columns_to_section() {
/>
</div>
<div class="column-actions">
<button class="btn btn-xs btn-icon" :title="__('Add Column')" @click="add_column">
<div v-html="frappe.utils.icon('add', 'sm')"></div>
</button>
<button
v-if="section.columns.indexOf(column)"
class="btn btn-xs btn-icon"
@ -128,9 +139,6 @@ function move_columns_to_section() {
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" :title="__('Add Column')" @click="add_column">
<div v-html="frappe.utils.icon('add', 'sm')"></div>
</button>
<button
class="btn btn-xs btn-icon"
:title="__('Remove Column')"
@ -145,9 +153,9 @@ function move_columns_to_section() {
</div>
<draggable
class="column-container"
:style="{ backgroundColor: column.fields.length ? '' : 'var(--field-placeholder-color)' }"
v-model="column.fields"
group="fields"
:delay="is_touch_screen_device() ? 200 : 0"
:animation="200"
:easing="store.get_animation"
item-key="id"
@ -161,11 +169,22 @@ function move_columns_to_section() {
/>
</template>
</draggable>
<div
class="empty-column"
:hidden="store.read_only"
:style="store.selected(column.df.name) ? { top: '35px' } : { top: 0 }"
>
<AddFieldButton :column="column" />
</div>
<div v-if="column.fields.length" class="add-new-field-btn">
<AddFieldButton :field="column.fields[column.fields.length - 1]" :column="column" />
</div>
</div>
</template>
<style lang="scss" scoped>
.column {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
@ -191,7 +210,7 @@ function move_columns_to_section() {
display: flex;
}
.column-container {
.column-container:empty {
height: 80%;
}
}
@ -247,9 +266,50 @@ function move_columns_to_section() {
}
.column-container {
flex: 1;
min-height: 2rem;
border-radius: var(--border-radius);
z-index: 1;
&:empty {
flex: 1;
& + .empty-column {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
position: absolute;
left: 0;
bottom: 0;
gap: 5px;
width: 100%;
padding: 15px;
button {
background-color: var(--bg-color);
z-index: 2;
&:hover {
background-color: var(--btn-default-hover-bg);
}
}
}
}
& + .empty-column {
display: none;
}
}
.add-new-field-btn {
padding: 10px 6px 5px;
button {
background-color: var(--white);
&:hover {
background-color: var(--btn-default-hover-bg);
}
}
}
}
</style>

View file

@ -5,13 +5,13 @@ let store = useStore();
const props = defineProps({
text: {
type: String
type: String,
},
placeholder: {
default: __("No Label")
default: __("No Label"),
},
empty_label: {
default: __("No Label")
default: __("No Label"),
},
});
@ -35,6 +35,8 @@ function focus_on_label() {
nextTick(() => input_text.value.focus());
}
}
defineExpose({ focus_on_label });
</script>
<template>
@ -48,12 +50,12 @@ function focus_on_label() {
:placeholder="placeholder"
:value="text"
:style="{ width: hidden_span_width }"
@input="event => $emit('update:modelValue', event.target.value)"
@input="(event) => $emit('update:modelValue', event.target.value)"
@keydown.enter="editing = false"
@blur="editing = false"
@click.stop
/>
<span v-else-if="text" v-html="text" ></span>
<span v-else-if="text" v-html="text"></span>
<i v-else class="text-muted">
{{ empty_label }}
</i>
@ -70,7 +72,6 @@ function focus_on_label() {
&:focus {
outline: none;
border-radius: var(--border-radius);
background-color: inherit;
}

View file

@ -1,14 +1,35 @@
<script setup>
import EditableInput from "./EditableInput.vue";
import { ref, computed } from "vue";
import { useStore } from "../store";
import { move_children_to_parent, clone_field } from "../utils";
import { ref, computed, onMounted } from "vue";
import AddFieldButton from "./AddFieldButton.vue";
import { useMagicKeys, whenever } from "@vueuse/core";
const props = defineProps(["column", "field"]);
let store = useStore();
const store = useStore();
let hovered = ref(false);
let component = computed(() => {
const add_field_ref = ref(null);
// cmd/ctrl + shift + n to open the add field autocomplete
const { ctrl_shift_n, Backspace } = useMagicKeys();
whenever(ctrl_shift_n, (value) => {
if (value && selected.value) {
add_field_ref.value.open();
}
});
// delete/backspace to delete the field
whenever(Backspace, (value) => {
if (value && selected.value && store.not_using_input) {
remove_field();
}
});
const label_input = ref(null);
const hovered = ref(false);
const selected = computed(() => store.selected(props.field.df.name));
const component = computed(() => {
return props.field.df.fieldtype.replace(" ", "") + "Control";
});
@ -23,8 +44,8 @@ function remove_field() {
}
function move_fields_to_column() {
let current_section = store.current_tab.sections.find(section =>
section.columns.find(column => column == props.column)
let current_section = store.current_tab.sections.find((section) =>
section.columns.find((column) => column == props.column)
);
move_children_to_parent(props, "column", "field", current_section);
}
@ -53,15 +74,13 @@ function duplicate_field() {
props.column.fields.splice(index + 1, 0, duplicate_field);
store.form.selected_field = duplicate_field.df;
}
onMounted(() => selected.value && label_input.value.focus_on_label());
</script>
<template>
<div
:class="[
'field',
hovered ? 'hovered' : '',
store.selected(field.df.name) ? 'selected' : ''
]"
:class="['field', selected ? 'selected' : hovered ? 'hovered' : '']"
:title="field.df.fieldname"
@click.stop="store.form.selected_field = field.df"
@mouseover.stop="hovered = true"
@ -76,23 +95,37 @@ function duplicate_field() {
<template #label>
<div class="field-label">
<EditableInput
ref="label_input"
:text="field.df.label"
:placeholder="__('Label')"
:empty_label="`${__('No Label')} (${field.df.fieldtype})`"
v-model="field.df.label"
/>
<div class="reqd-asterisk" v-if="field.df.reqd">*</div>
<div class="help-icon" v-if="field.df.documentation_url" v-html="frappe.utils.icon('help', 'sm')"></div>
<div
class="help-icon"
v-if="field.df.documentation_url"
v-html="frappe.utils.icon('help', 'sm')"
></div>
</div>
</template>
<template #actions>
<div class="field-actions" :hidden="store.read_only">
<button
v-if="field.df.fieldtype == 'HTML'"
class="btn btn-xs btn-icon"
@click="edit_html"
<AddFieldButton
v-if="column.fields.indexOf(field) != column.fields.length - 1"
ref="add_field_ref"
:field="field"
:column="column"
:tooltip="__('Add field below')"
>
<div v-html="frappe.utils.icon('edit', 'sm')"></div>
<div v-html="frappe.utils.icon('add', 'sm')" />
</AddFieldButton>
<button
class="btn btn-xs btn-icon"
:title="__('Duplicate field')"
@click.stop="duplicate_field"
>
<div v-html="frappe.utils.icon('duplicate', 'sm')"></div>
</button>
<button
v-if="column.fields.indexOf(field)"
@ -104,10 +137,11 @@ function duplicate_field() {
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" @click.stop="duplicate_field">
<div v-html="frappe.utils.icon('duplicate', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" @click.stop="remove_field">
<button
class="btn btn-xs btn-icon"
:title="__('Remove field')"
@click.stop="remove_field"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>

View file

@ -68,7 +68,16 @@ let docfield_df = computed(() => {
</script>
<template>
<SearchBox v-model="search_text" />
<div class="header">
<SearchBox class="flex-1" v-model="search_text" />
<button
class="close-btn btn btn-xs"
:title="__('Close properties')"
@click="store.form.selected_field = null"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>
<div class="control-data">
<div v-if="store.form.selected_field">
<div class="field" v-for="(df, i) in docfield_df" :key="i">
@ -88,8 +97,17 @@ let docfield_df = computed(() => {
</template>
<style lang="scss" scoped>
.header {
display: flex;
padding: 5px;
border-bottom: 1px solid var(--border-color);
.close-btn {
margin-right: -5px;
}
}
.control-data {
height: calc(100vh - 150px);
height: calc(100vh - 202px);
overflow-y: auto;
padding: 8px;

View file

@ -1,93 +0,0 @@
<script setup>
import SearchBox from "./SearchBox.vue";
import draggable from "vuedraggable";
import { ref, computed } from "vue";
import { useStore } from "../store";
import { clone_field } from "../utils";
let store = useStore();
let search_text = ref("");
let fields = computed(() => {
let fields = frappe.model.all_fieldtypes
.filter(df => {
if (in_list(frappe.model.layout_fields, df)) {
return false;
}
if (search_text.value) {
if (df.toLowerCase().includes(search_text.value.toLowerCase())) {
return true;
}
return false;
} else {
return true;
}
})
.map(df => {
let out = {
df: store.get_df(df),
table_columns: [],
};
return out;
});
return [...fields];
});
function on_drag_start(evt) {
$(evt.item).html('<div class="drop-it-here"></div>');
}
function on_drag_end(evt) {
let old_html = evt.clone.innerHTML;
$(evt.item).html(old_html);
}
</script>
<template>
<SearchBox v-model="search_text" />
<draggable
class="fields-container"
:list="fields"
:group="{ name: 'fields', pull: 'clone', put: false }"
:sort="false"
:clone="clone_field"
item-key="id"
:remove-clone-on-hide="false"
@start="on_drag_start"
@end="on_drag_end"
>
<template #item="{ element }">
<div class="field" :title="element.df.fieldtype">
{{ element.df.fieldtype }}
</div>
</template>
</draggable>
</template>
<style lang="scss" scoped>
.fields-container {
height: calc(100vh - 133px);
overflow-y: auto;
display: grid;
gap: 8px;
padding: 8px;
grid-template-columns: 1fr 1fr;
grid-auto-rows: max-content;
.field {
display: block !important;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
border: 0.5px solid var(--dark-border-color);
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
cursor: pointer;
&.sortable-ghost {
position: absolute;
opacity: 0;
}
}
}
</style>

View file

@ -5,7 +5,7 @@
<input
class="search-input form-control"
type="text"
:placeholder="__('Search fields')"
:placeholder="__('Search properties...')"
@input="event => $emit('update:modelValue', event.target.value)"
/>
<span class="search-icon">
@ -18,9 +18,8 @@
.search-box {
display: flex;
position: relative;
padding: 0px 9px 9px;
background-color: var(--fg-color);
border-bottom: 1px solid var(--border-color);
width: 100%;
.search-input {
padding-left: 30px;
@ -28,7 +27,7 @@
.search-icon {
position: absolute;
left: 16px;
left: 7px;
top: 2px;
}
}

View file

@ -2,15 +2,25 @@
import draggable from "vuedraggable";
import Column from "./Column.vue";
import EditableInput from "./EditableInput.vue";
import { ref } from "vue";
import { ref, computed } from "vue";
import { useStore } from "../store";
import { section_boilerplate, move_children_to_parent, confirm_dialog } from "../utils";
import { section_boilerplate, move_children_to_parent, confirm_dialog, is_touch_screen_device } from "../utils";
import { useMagicKeys, whenever } from "@vueuse/core";
const props = defineProps(["tab", "section"]);
let store = useStore();
const store = useStore();
let hovered = ref(false);
let collapsed = ref(false);
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.not_using_input) {
remove_section();
}
});
const hovered = ref(false);
const collapsed = ref(false);
const selected = computed(() => store.selected(props.section.df.name));
function add_section_above() {
let index = props.tab.sections.indexOf(props.section);
@ -19,7 +29,7 @@ function add_section_above() {
function is_section_empty() {
return !props.section.columns.some(
column => (store.is_customize_form && !column.df.is_custom_field) || column.fields.length
(column) => (store.is_customize_form && !column.df.is_custom_field) || column.fields.length
);
}
@ -34,11 +44,15 @@ function remove_section() {
} else {
confirm_dialog(
__("Delete Section", null, "Title of confirmation dialog"),
__("Are you sure you want to delete the section? All the columns along with fields in the section will be moved to the previous section.", null, "Confirmation dialog message"),
__(
"Are you sure you want to delete the section? All the columns along with fields in the section will be moved to the previous section.",
null,
"Confirmation dialog message"
),
() => delete_section(),
__("Delete section", null, "Button text"),
() => delete_section(true),
__("Delete entire section with columns", null, "Button text")
__("Delete entire section with fields", null, "Button text")
);
}
}
@ -94,7 +108,7 @@ function move_sections_to_tab() {
:class="[
'form-section',
hovered ? 'hovered' : '',
store.selected(section.df.name) ? 'selected' : ''
store.selected(section.df.name) ? 'selected' : '',
]"
:title="section.df.fieldname"
@click.stop="select_section"
@ -105,7 +119,7 @@ function move_sections_to_tab() {
:class="[
'section-header',
section.df.label || section.df.collapsible ? 'has-label' : '',
collapsed ? 'collapsed' : ''
collapsed ? 'collapsed' : '',
]"
:hidden="!section.df.label && store.read_only"
>
@ -118,18 +132,10 @@ function move_sections_to_tab() {
<div
v-if="section.df.collapsible"
class="collapse-indicator"
v-html="frappe.utils.icon( collapsed ? 'down' : 'up-line', 'sm' )"
v-html="frappe.utils.icon(collapsed ? 'down' : 'up-line', 'sm')"
></div>
</div>
<div class="section-actions" :hidden="store.read_only">
<button
v-if="tab.sections.indexOf(section)"
class="btn btn-xs btn-section"
:title="__('Move the current section and the following sections to a new tab')"
@click="move_sections_to_tab"
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button
class="btn btn-xs btn-section"
:title="__('Add section above')"
@ -137,6 +143,16 @@ function move_sections_to_tab() {
>
<div v-html="frappe.utils.icon('add', 'sm')"></div>
</button>
<button
v-if="tab.sections.indexOf(section)"
class="btn btn-xs btn-section"
:title="
__('Move the current section and the following sections to a new tab')
"
@click="move_sections_to_tab"
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button
class="btn btn-xs btn-section"
:title="__('Remove section')"
@ -146,22 +162,22 @@ function move_sections_to_tab() {
</button>
</div>
</div>
<div v-if="section.df.description" class="section-description">{{ section.df.description }}</div>
<div v-if="section.df.description" class="section-description">
{{ section.df.description }}
</div>
<div
class="section-columns"
:class="{
hidden: section.df.collapsible && collapsed,
'has-one-column': section.columns.length === 1
'has-one-column': section.columns.length === 1,
}"
>
<draggable
class="section-columns-container"
:style="{
backgroundColor: section.columns.length ? null : 'var(--field-placeholder-color)'
}"
v-model="section.columns"
group="columns"
item-key="id"
:delay="is_touch_screen_device() ? 200 : 0"
:animation="200"
:easing="store.get_animation"
:disabled="store.read_only"

View file

@ -1,13 +1,10 @@
<script setup>
import FieldTypes from "./FieldTypes.vue";
import FieldProperties from "./FieldProperties.vue";
import { ref, watch } from "vue";
import { useStore } from "../store";
import { ref } from "vue";
let store = useStore();
let tab_titles = [__("Field Types"), __("Field Properties")];
let active_tab = ref(tab_titles[0]);
let sidebar_width = ref(272);
let sidebar_resizing = ref(false);
@ -23,7 +20,8 @@ function start_resize() {
function resize(e) {
sidebar_resizing.value = true;
$(".form-builder-container").addClass("resizing");
sidebar_width.value = e.clientX - 90;
let screen_width = e.view.innerWidth;
sidebar_width.value = screen_width - e.clientX - 90;
if (sidebar_width.value < 16 * 16) {
sidebar_width.value = 16 * 16;
@ -32,14 +30,6 @@ function resize(e) {
sidebar_width.value = 24 * 16;
}
}
watch(
() => store.form.selected_field,
value => {
active_tab.value = value ? tab_titles[1] : tab_titles[0];
},
{ deep: true }
);
</script>
<template>
@ -48,21 +38,20 @@ watch(
@mousedown="start_resize"
/>
<div class="sidebar-container" :style="{ width: `${sidebar_width}px` }">
<div class="tab-header">
<div
:class="['tab', active_tab == tab ? 'active' : '']"
v-for="(tab, i) in tab_titles"
:key="i"
@click="active_tab = tab"
>
{{ tab }}
<FieldProperties v-if="store.form.selected_field" />
<div class="default-state" v-else>
<div class="actions" v-if="store.form.layout.tabs.length == 1 && !store.read_only">
<button
class="new-tab-btn btn btn-default btn-xs"
:title="__('Add new tab')"
@click="store.add_new_tab"
>
{{ __("Add tab") }}
</button>
</div>
<div class="empty-state">
<div>Select a field to edit its properties.</div>
</div>
</div>
<div :class="['tab-content', active_tab == tab_titles[0] ? 'active' : '']">
<FieldTypes />
</div>
<div :class="['tab-content', active_tab == tab_titles[1] ? 'active' : '']">
<FieldProperties />
</div>
</div>
</template>
@ -71,7 +60,7 @@ watch(
.sidebar-resizer {
position: absolute;
top: 0;
right: -6px;
left: -5px;
width: 5px;
height: 100%;
opacity: 0;
@ -80,10 +69,12 @@ watch(
z-index: 4;
cursor: col-resize;
&:hover, &.resizing {
&:hover,
&.resizing {
opacity: 1;
}
}
.tab-header {
display: flex;
justify-content: space-between;
@ -127,4 +118,25 @@ watch(
display: block;
}
}
.default-state {
display: flex;
flex-direction: column;
height: calc(100vh - 163px);
.actions {
padding: 5px;
display: flex;
justify-content: flex-end;
border-bottom: 1px solid var(--border-color);
}
.empty-state {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: var(--disabled-text-color);
}
}
</style>

View file

@ -1,29 +1,29 @@
<script setup>
import Section from "./Section.vue";
import EditableInput from "./EditableInput.vue";
import draggable from "vuedraggable";
import { useStore } from "../store";
import { section_boilerplate, confirm_dialog } from "../utils";
import { ref, computed, nextTick } from "vue";
import { section_boilerplate, confirm_dialog, is_touch_screen_device } from "../utils";
import draggable from "vuedraggable";
import { ref, computed } from "vue";
import { useMagicKeys, whenever } from "@vueuse/core";
let store = useStore();
const store = useStore();
let dragged = ref(false);
let has_tabs = computed(() => store.form.layout.tabs.length > 1);
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && selected.value && store.not_using_input) {
remove_tab(store.current_tab, '', true);
}
});
const dragged = ref(false);
const selected = computed(() => store.selected(store.current_tab.df.name));
const has_tabs = computed(() => store.form.layout.tabs.length > 1);
store.form.active_tab = store.form.layout.tabs[0].df.name;
function activate_tab(tab) {
store.form.active_tab = tab.df.name;
store.form.selected_field = tab.df;
// scroll to active tab
nextTick(() => {
$(".tabs .tab.active")[0].scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
});
store.activate_tab(tab);
}
function drag_over(tab) {
@ -34,13 +34,7 @@ function drag_over(tab) {
}
function add_new_tab() {
let tab = {
df: store.get_df("Tab Break", "", "Tab " + (store.form.layout.tabs.length + 1)),
sections: [section_boilerplate()],
};
store.form.layout.tabs.push(tab);
activate_tab(tab);
store.add_new_tab();
}
function add_new_section() {
@ -49,49 +43,56 @@ function add_new_section() {
store.form.selected_field = section.df;
}
function is_current_tab_empty() {
function is_tab_empty(tab) {
// check if sections have columns and it contains fields
return !store.current_tab.sections.some(
section => section.columns.some(column => column.fields.length)
return !tab.sections.some((section) =>
section.columns.some((column) => column.fields.length)
);
}
function remove_tab() {
function remove_tab(tab, event, force=false) {
// is remove_tab_btn is not visible then return
if (!event?.currentTarget?.offsetParent && !force) return;
if (store.is_customize_form && store.current_tab.df.is_custom_field == 0) {
frappe.msgprint(__("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
} else if (store.has_standard_field(store.current_tab)) {
delete_tab();
} else if (is_current_tab_empty()) {
delete_tab(true);
delete_tab(tab);
} else if (is_tab_empty(tab)) {
delete_tab(tab, true);
} else {
confirm_dialog(
__("Delete Tab", null, "Title of confirmation dialog"),
__("Are you sure you want to delete the tab? All the sections along with fields in the tab will be moved to the previous tab.", null, "Confirmation dialog message"),
() => delete_tab(),
__(
"Are you sure you want to delete the tab? All the sections along with fields in the tab will be moved to the previous tab.",
null,
"Confirmation dialog message"
),
() => delete_tab(tab),
__("Delete tab", null, "Button text"),
() => delete_tab(true),
__("Delete entire tab with sections", null, "Button text")
() => delete_tab(tab, true),
__("Delete entire tab with fields", null, "Button text")
);
}
}
function delete_tab(with_children) {
function delete_tab(tab, with_children) {
let tabs = store.form.layout.tabs;
let index = tabs.indexOf(store.current_tab);
let index = tabs.indexOf(tab);
if (!with_children) {
if (index > 0) {
let prev_tab = tabs[index - 1];
if (!is_current_tab_empty()) {
if (!is_tab_empty(tab)) {
// move all sections from current tab to previous tab
prev_tab.sections = [...prev_tab.sections, ...store.current_tab.sections];
prev_tab.sections = [...prev_tab.sections, ...tab.sections];
}
} else {
// create a new tab and push sections to it
tabs.unshift({
df: store.get_df("Tab Break", "", __("Details")),
sections: store.current_tab.sections,
sections: tab.sections,
is_first: true,
});
index++;
@ -109,12 +110,13 @@ function delete_tab(with_children) {
</script>
<template>
<div class="tab-header" v-if="!(store.form.layout.tabs.length == 1 && store.read_only)">
<div class="tab-header" v-if="store.form.layout.tabs.length > 1">
<draggable
v-show="has_tabs"
class="tabs"
v-model="store.form.layout.tabs"
group="tabs"
:delay="is_touch_screen_device() ? 200 : 0"
:animation="200"
:easing="store.get_animation"
item-key="id"
@ -135,6 +137,14 @@ function delete_tab(with_children) {
:placeholder="__('Tab Label')"
v-model="element.df.label"
/>
<button
class="remove-tab-btn btn btn-xs"
:title="__('Remove tab')"
@click.stop="remove_tab(element, $event)"
:hidden="store.read_only"
>
<div v-html="frappe.utils.icon('remove', 'xs')"></div>
</button>
</div>
</template>
</draggable>
@ -145,19 +155,10 @@ function delete_tab(with_children) {
:title="__('Add new tab')"
@click="add_new_tab"
>
<div v-if="has_tabs" v-html="frappe.utils.icon('add', 'sm')"></div>
<div class="add-btn-text" v-else>
{{ __("Add new tab") }}
<div class="add-btn-text">
{{ __("Add tab") }}
</div>
</button>
<button
v-if="has_tabs"
class="remove-tab-btn btn btn-xs"
:title="__('Remove selected tab')"
@click="remove_tab"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>
</div>
@ -172,6 +173,7 @@ function delete_tab(with_children) {
class="tab-content-container"
v-model="tab.sections"
group="sections"
:delay="is_touch_screen_device() ? 200 : 0"
:animation="200"
:easing="store.get_animation"
item-key="id"
@ -186,8 +188,8 @@ function delete_tab(with_children) {
</template>
</draggable>
<div class="empty-tab" :hidden="store.read_only">
<div>{{ __("Drag & Drop a section here from another tab") }}</div>
<div>{{ __("OR") }}</div>
<div v-if="has_tabs">{{ __("Drag & Drop a section here from another tab") }}</div>
<div v-if="has_tabs">{{ __("OR") }}</div>
<button class="btn btn-default btn-sm" @click="add_new_section">
{{ __("Add a new section") }}
</button>
@ -200,7 +202,7 @@ function delete_tab(with_children) {
.tab-header {
display: flex;
justify-content: space-between;
min-height: 53px;
min-height: 42px;
align-items: center;
background-color: var(--fg-color);
border-bottom: 1px solid var(--border-color);
@ -208,12 +210,6 @@ function delete_tab(with_children) {
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
&:hover {
.tab-actions .btn {
opacity: 1 !important;
}
}
.tabs {
display: flex;
flex: 1;
@ -225,7 +221,7 @@ function delete_tab(with_children) {
margin-right: 20px;
.btn {
opacity: 0;
background-color: var(--control-bg);
padding: 2px;
margin-left: 4px;
box-shadow: none;
@ -235,7 +231,7 @@ function delete_tab(with_children) {
}
&:hover {
background-color: var(--border-color);
background-color: var(--btn-default-hover-bg);
}
}
@ -246,8 +242,10 @@ function delete_tab(with_children) {
}
.tab {
display: flex;
align-items: center;
position: relative;
padding: var(--padding-md);
padding: 10px 18px 10px 15px;
color: var(--text-muted);
min-width: max-content;
cursor: pointer;
@ -275,11 +273,22 @@ function delete_tab(with_children) {
border-color: var(--primary);
}
}
&:hover .remove-tab-btn {
display: block;
}
.remove-tab-btn {
position: absolute;
right: -2px;
display: none;
padding: 2px;
}
}
}
.tab-contents {
max-height: calc(100vh - 110px);
max-height: calc(100vh - 217px);
overflow-y: auto;
overflow-x: hidden;
border-radius: var(--border-radius);
@ -290,13 +299,14 @@ function delete_tab(with_children) {
position: relative;
&.active {
display: block;
display: flex;
}
.tab-content-container {
flex: 1;
min-height: 4rem;
background-color: var(--field-placeholder-color);
border-radius: var(--border-radius);
z-index: 1;
&:empty {
height: 7rem;
@ -306,14 +316,16 @@ function delete_tab(with_children) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
bottom: 0;
gap: 5px;
width: 100%;
padding: 15px;
&button:hover {
background-color: var(--border-color);
button {
z-index: 2;
}
}
}

View file

@ -1,7 +1,12 @@
import { defineStore } from "pinia";
import { create_layout, scrub_field_names, load_doctype_model } from "./utils";
import {
create_layout,
scrub_field_names,
load_doctype_model,
section_boilerplate,
} from "./utils";
import { computed, nextTick, ref } from "vue";
import { useDebouncedRefHistory, onKeyDown } from "@vueuse/core";
import { useDebouncedRefHistory, onKeyDown, useActiveElement } from "@vueuse/core";
export const useStore = defineStore("form-builder-store", () => {
let doctype = ref("");
@ -31,6 +36,15 @@ export const useStore = defineStore("form-builder-store", () => {
return form.value.layout.tabs.find((tab) => tab.df.name == form.value.active_tab);
});
const active_element = useActiveElement();
const not_using_input = computed(
() =>
active_element.value?.readOnly ||
active_element.value?.disabled ||
(active_element.value?.tagName !== "INPUT" &&
active_element.value?.tagName !== "TEXTAREA")
);
// Actions
function selected(name) {
return form.value.selected_field?.name == name;
@ -297,6 +311,31 @@ export const useStore = defineStore("form-builder-store", () => {
return create_layout(doc.value.fields);
}
// Tab actions
function add_new_tab() {
let tab = {
df: get_df("Tab Break", "", "Tab " + (form.value.layout.tabs.length + 1)),
sections: [section_boilerplate()],
};
form.value.layout.tabs.push(tab);
activate_tab(tab);
}
function activate_tab(tab) {
form.value.active_tab = tab.df.name;
form.value.selected_field = tab.df;
// scroll to active tab
nextTick(() => {
$(".tabs .tab.active")[0]?.scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
});
}
return {
doctype,
frm,
@ -310,6 +349,7 @@ export const useStore = defineStore("form-builder-store", () => {
get_animation,
get_docfields,
current_tab,
not_using_input,
selected,
get_df,
has_standard_field,
@ -320,5 +360,7 @@ export const useStore = defineStore("form-builder-store", () => {
get_updated_fields,
is_df_updated,
get_layout,
add_new_tab,
activate_tab,
};
});

View file

@ -242,10 +242,6 @@ export function section_boilerplate() {
df: store.get_df("Column Break"),
fields: [],
},
{
df: store.get_df("Column Break"),
fields: [],
},
],
};
}
@ -351,3 +347,7 @@ export function confirm_dialog(
d.show();
d.set_message(message);
}
export function is_touch_screen_device() {
return "ontouchstart" in document.documentElement;
}

View file

@ -1,15 +1,15 @@
frappe.provide("frappe.data_import");
frappe.data_import.DataExporter = class DataExporter {
constructor(doctype, exporting_for) {
constructor(doctype, exporting_for, filetype = "CSV") {
this.doctype = doctype;
this.exporting_for = exporting_for;
frappe.model.with_doctype(doctype, () => {
this.make_dialog();
this.make_dialog(filetype);
});
}
make_dialog() {
make_dialog(filetype = "CSV") {
this.dialog = new frappe.ui.Dialog({
title: __("Export Data"),
fields: [
@ -18,7 +18,7 @@ frappe.data_import.DataExporter = class DataExporter {
fieldname: "file_type",
label: __("File Type"),
options: ["Excel", "CSV"],
default: "CSV",
default: filetype,
},
{
fieldtype: "Select",

View file

@ -32,8 +32,22 @@ frappe.dom = {
// execute the script globally
document.getElementsByTagName("head")[0].appendChild(el);
},
remove_script_and_style: function (txt) {
const evil_tags = ["script", "style", "noscript", "title", "meta", "base", "head"];
const unsafe_tags = ["link"];
if (!this.unsafe_tags_regex) {
const evil_and_unsafe_tags = evil_tags.concat(unsafe_tags);
const regex_str = evil_and_unsafe_tags.map((t) => `<([\\s]*)${t}`).join("|");
this.unsafe_tags_regex = new RegExp(regex_str, "im");
}
// if no unsafe tags are present return as is to prevent unncessary expensive parsing
if (!txt || !this.unsafe_tags_regex.test(txt)) {
return txt;
}
const parser = new DOMParser();
const doc = parser.parseFromString(txt, "text/html");
const body = doc.body;

View file

@ -1240,6 +1240,10 @@ frappe.ui.form.Form = class FrappeForm {
frappe.set_route("print", this.doctype, this.doc.name);
}
show_audit_trail() {
frappe.set_route("audit-trail");
}
navigate_records(prev) {
let filters, sort_field, sort_order;
let list_view = frappe.get_list_view(this.doctype);
@ -2039,6 +2043,8 @@ frappe.ui.form.Form = class FrappeForm {
this.active_tab_map = {};
}
this.active_tab_map[this.docname] = tab;
this.script_manager.trigger("on_tab_change");
}
get_active_tab() {
return this.active_tab_map && this.active_tab_map[this.docname];

View file

@ -310,6 +310,11 @@ export default class Grid {
this.remove_all_rows_button.toggleClass("hidden", !show_delete_all_btn);
}
debounced_refresh_remove_rows_button = frappe.utils.debounce(
this.refresh_remove_rows_button,
100
);
get_selected() {
return (this.grid_rows || [])
.map((row) => {
@ -1082,6 +1087,9 @@ export default class Grid {
new frappe.ui.FileUploader({
as_dataurl: true,
allow_multiple: false,
restrictions: {
allowed_file_types: [".csv"],
},
on_success(file) {
var data = frappe.utils.csv_to_array(
frappe.utils.get_decoded_string(file.dataurl)

View file

@ -90,7 +90,7 @@ export default class GridRow {
this.wrapper
.find(".grid-row-check")
.prop("checked", this.doc ? !!this.doc.__checked : false);
this.grid.refresh_remove_rows_button();
this.grid.debounced_refresh_remove_rows_button();
}
remove() {
var me = this;

View file

@ -161,9 +161,16 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
if (data) {
me.dialog.working = true;
me.dialog.set_message(__("Saving..."));
me.insert().then(() => {
me.dialog.clear_message();
let messagetxt = __("Created new {0} {1}", [
__(me.doctype),
this.doc.name.bold(),
]);
me.dialog.animation_speed = "slow";
me.dialog.hide();
setTimeout(function () {
frappe.show_alert({ message: messagetxt, indicator: "green" }, 3);
}, 500);
});
}
});
@ -189,19 +196,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
},
]);
} else {
me.dialog.hide();
// delete the old doc
frappe.model.clear_doc(me.dialog.doc.doctype, me.dialog.doc.name);
me.dialog.doc = r.message;
if (frappe._from_link) {
frappe.ui.form.update_calling_link(me.dialog.doc);
} else {
if (me.after_insert) {
me.after_insert(me.dialog.doc);
} else {
me.open_form_if_not_list();
}
}
me.process_after_insert(r);
}
},
error: function () {
@ -213,7 +208,6 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
me.dialog.working = false;
resolve(me.dialog.doc);
},
freeze: true,
});
});
}
@ -226,25 +220,25 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
doc: doc,
},
callback: function (r) {
me.dialog.hide();
// delete the old doc
frappe.model.clear_doc(me.dialog.doc.doctype, me.dialog.doc.name);
me.dialog.doc = r.message;
if (frappe._from_link) {
frappe.ui.form.update_calling_link(me.dialog.doc);
} else {
if (me.after_insert) {
me.after_insert(me.dialog.doc);
} else {
me.open_form_if_not_list();
}
}
me.process_after_insert(r);
cur_frm && cur_frm.reload_doc();
},
});
}
process_after_insert(r) {
// delete the old doc
frappe.model.clear_doc(this.dialog.doc.doctype, this.dialog.doc.name);
this.dialog.doc = r.message;
if (frappe._from_link) {
frappe.ui.form.update_calling_link(this.dialog.doc);
} else if (this.after_insert) {
this.after_insert(this.dialog.doc);
} else {
this.open_form_if_not_list();
}
}
open_form_if_not_list() {
let route = frappe.get_route();
let doc = this.dialog.doc;

View file

@ -1,5 +1,5 @@
<div class="timeline-message-box" data-communication-type="{{ doc.communication_type }}">
<span class="flex justify-between m-1">
<span class="flex justify-between m-1 mb-3">
<span class="text-color flex">
{% if (doc.communication_type && doc.communication_type == "Automated Message") { %}
<span>

View file

@ -498,6 +498,19 @@ frappe.ui.form.Toolbar = class Toolbar {
}
);
}
if (
this.frm.doc.amended_from &&
frappe.model.get_value("DocType", this.frm.doc.doctype, "track_changes")
) {
this.page.add_menu_item(
__("View Audit Trail"),
function () {
me.frm.show_audit_trail();
},
true
);
}
}
make_customize_buttons() {

View file

@ -16,7 +16,7 @@ $.extend(frappe.meta, {
$.each(doc.fields, function (i, df) {
frappe.meta.add_field(df);
});
frappe.meta.sync_messages(doc);
if (doc.__print_formats) frappe.model.sync(doc.__print_formats);
if (doc.__workflow_docs) frappe.model.sync(doc.__workflow_docs);
},
@ -281,12 +281,6 @@ $.extend(frappe.meta, {
return print_format_list;
},
sync_messages: function (doc) {
if (doc.__messages) {
$.extend(frappe._messages, doc.__messages);
}
},
get_field_currency: function (df, doc) {
var currency = frappe.boot.sysdefaults.currency;
if (!doc && cur_frm) doc = cur_frm.doc;

View file

@ -91,7 +91,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
me.is_minimized = false;
me.hide_scrollbar(false);
// hide any grid row form if open
frappe.ui.form.get_open_grid_form()?.hide_form();
frappe.ui.form.get_open_grid_form?.()?.hide_form();
if (frappe.ui.open_dialogs[frappe.ui.open_dialogs.length - 1] === me) {
frappe.ui.open_dialogs.pop();
@ -254,6 +254,10 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
}
hide() {
if (this.animate && this.animation_speed === "slow") {
this.$wrapper.addClass("slow");
$(".modal-backdrop").addClass("slow");
}
this.$wrapper.modal("hide");
this.is_visible = false;
}

View file

@ -81,8 +81,17 @@ function fuzzy_match_recursive(
// Loop through pattern and str looking for a match.
let first_match = true;
while (pattern_cur_index < pattern.length && str_curr_index < str.length) {
// Normalize and compare individual characters
const normalized_pattern_char = pattern[pattern_cur_index]
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
const normalized_str_char = str[str_curr_index]
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
// Match found.
if (pattern[pattern_cur_index].toLowerCase() === str[str_curr_index].toLowerCase()) {
if (normalized_pattern_char === normalized_str_char) {
if (next_match >= max_matches) {
return [false, out_score, matches];
}

View file

@ -12,7 +12,7 @@ $.extend(frappe.contacts, {
$(frm.fields_dict["address_html"].wrapper)
.html(frappe.render_template("address_list", frm.doc.__onload))
.find(".btn-address")
.on("click", () => new_record("Address", frm.doctype, frm.doc.name));
.on("click", () => new_record("Address", frm.doc));
}
// render contact
@ -20,7 +20,7 @@ $.extend(frappe.contacts, {
$(frm.fields_dict["contact_html"].wrapper)
.html(frappe.render_template("contact_list", frm.doc.__onload))
.find(".btn-contact")
.on("click", () => new_record("Contact", frm.doctype, frm.doc.name));
.on("click", () => new_record("Contact", frm.doc));
}
},
get_last_doc: function (frm) {
@ -59,14 +59,12 @@ $.extend(frappe.contacts, {
},
});
function new_record(doctype, link_doctype, link_name) {
return frappe.new_doc(doctype).then(() => {
if (cur_frm.doc.links) {
// avoid adding the same link twice
return;
}
function new_record(doctype, source_doc) {
frappe.dynamic_link = {
doctype: source_doc.doctype,
doc: source_doc,
fieldname: "name",
};
cur_frm.add_child("links", { link_doctype: link_doctype, link_name: link_name });
cur_frm.refresh_field("links");
});
return frappe.new_doc(doctype);
}

View file

@ -354,7 +354,7 @@ function get_doc_mappings() {
due_date: "date",
reference_doctype: "reference_type",
reference_document: "reference_name",
assigned_to: "owner",
assigned_to: "allocated_to",
},
reqd_fields: ["description"],
hidden_fields: ["public", "category"],

View file

@ -36,7 +36,7 @@ class PrintFormatBuilder {
() => this.$component.$store.dirty,
(dirty) => {
if (dirty.value) {
this.page.set_indicator("Not Saved", "orange");
this.page.set_indicator(__("Not Saved"), "orange");
$toggle_preview_btn.hide();
$reset_changes_btn.show();
} else {

View file

@ -43,7 +43,7 @@ class TelemetryManager {
}
can_enable() {
return Boolean(this.telemetry_host && this.project_id);
return Boolean(this.telemetry_host && this.project_id && !cint(navigator.doNotTrack));
}
send_heartbeat() {

View file

@ -349,6 +349,13 @@
display: none;
}
}
.editable-row {
.markdown-container {
position: relative;
z-index: 1;
}
}
}
@mixin base-grid() {

View file

@ -154,6 +154,24 @@ body.modal-open[style^="padding-right"] {
opacity: 0.8;
}
.fade.slow {
transition: opacity 0.4s linear;
}
.modal.fade .modal-dialog {
transition: transform 0.2s ease;
transform: translateY(-15%);
}
.modal.fade.slow .modal-dialog {
transition: transform 0.4s ease;
transform: translateY(-25%);
}
.modal.show .modal-dialog {
transform: none;
}
.modal-minimize {
position: initial;
height: 0;

View file

@ -407,15 +407,17 @@ body[data-route^="Module"] .main-menu {
margin-bottom: 3px;
max-width: 100%;
.data-pill {
@include get_textstyle("sm", "regular");
justify-content: space-between;
box-shadow: none;
line-height: 1;
}
}
.attachment-row {
.data-pill {
background-color: unset;
box-shadow: none;
padding:0 var(--padding-xs) 0 var(--padding-md);
padding: 0 var(--padding-xs) 0 var(--padding-md) !important;
}
}

View file

@ -90,6 +90,7 @@ $threshold: 34;
}
}
.timeline-content {
position: relative;
max-width: var(--timeline-content-max-width);
padding: var(--padding-sm);
margin-left: var(--margin-md);
@ -111,6 +112,7 @@ $threshold: 34;
background-color: transparent;
border: none;
font-weight: var(--weight-semibold);
padding: 0;
}
}
}
@ -130,6 +132,15 @@ $threshold: 34;
.content {
overflow: auto;
max-height: 500px;
&::before {
content: '';
display: block;
height: 1px;
margin-top: -10px;
width: calc(100% - 64px);
position: absolute;
background-color: var(--border-color);
}
}
.actions {
@ -146,6 +157,9 @@ $threshold: 34;
background-color: transparent;
--icon-stroke: var(--text-muted);
}
.action-btn, .custom-actions {
@include get_textstyle("sm", "regular");
}
.action-btn:hover {
text-decoration: none;

View file

@ -5,7 +5,7 @@ html {
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@include get_textstyle("base", "regular");
@include get_textstyle("lg", "regular");
color: $body-color;
display: flex;
flex-direction: column;

View file

@ -104,7 +104,7 @@
margin-bottom: 3rem;
margin-top: 5rem;
}
a {
.from-markdown a {
text-decoration: underline;
}
}

View file

@ -1,9 +1,4 @@
@import '../common/css_variables.scss';
@import "../espresso/colors";
@import "../espresso/spacing";
@import "../espresso/typography";
@import "../espresso/shadows";
@import "../espresso/borders";
// Deprecated but remove after all use is removed as well.
:root {

View file

@ -1,5 +1,5 @@
@import 'variables';
@import "frappe/public/css/fonts/inter/inter.css";
@import "../../css/fonts/inter/inter.scss";
@import '../common/quill';
@import '~bootstrap/scss/bootstrap';
@import '~cropperjs/dist/cropper.min';
@ -168,7 +168,7 @@ a.card {
text-decoration: none;
}
a.card-link {
a.card-link, a.card-link:hover {
text-decoration: underline;
}

View file

@ -22,6 +22,17 @@
@media (max-width: map-get($grid-breakpoints, "lg")) {
.navbar {
padding: 0 1rem;
.navbar-search {
width: 75vw;
}
}
}
@media (max-width: map-get($grid-breakpoints, "sm")) {
.navbar-collapse.collapse.show {
.navbar-nav {
align-items: flex-start;
}
}
}

View file

@ -78,8 +78,6 @@ def main(
if not scheduler_disabled_by_user:
frappe.utils.scheduler.disable_scheduler()
set_test_email_config()
if not frappe.flags.skip_before_tests:
if verbose:
print('Running "before_tests" hooks')
@ -126,17 +124,6 @@ def main(
xmloutput_fh.close()
def set_test_email_config():
frappe.conf.update(
{
"auto_email_id": "test@example.com",
"mail_server": "smtp.example.com",
"mail_login": "test@example.com",
"mail_password": "test",
}
)
class TimeLoggingTestResult(unittest.TextTestResult):
def startTest(self, test):
self._started_at = time.monotonic()

View file

@ -278,6 +278,14 @@ class TestMethodAPI(FrappeAPITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json["message"], "Administrator")
authorization_token = f"{api_key}:INCORRECT"
response = self.get(self.method_path("frappe.auth.get_logged_user"))
self.assertEqual(response.status_code, 401)
authorization_token = f"NonExistentKey:INCORRECT"
response = self.get(self.method_path("frappe.auth.get_logged_user"))
self.assertEqual(response.status_code, 401)
authorization_token = None
def test_404s(self):

View file

@ -5,6 +5,8 @@ import email
import re
from unittest.mock import patch
import requests
import frappe
from frappe.email.doctype.email_account.test_email_account import TestEmailAccount
from frappe.email.doctype.email_queue.email_queue import QueueBuilder
@ -52,7 +54,7 @@ class TestEmail(FrappeTestCase):
self.test_email_queue(send_after=1)
from frappe.email.queue import flush
flush(from_test=True)
flush()
email_queue = frappe.db.sql(
"""select name from `tabEmail Queue` where status='Sent'""", as_dict=1
)
@ -62,7 +64,7 @@ class TestEmail(FrappeTestCase):
self.test_email_queue()
from frappe.email.queue import flush
flush(from_test=True)
flush()
email_queue = frappe.db.sql(
"""select name from `tabEmail Queue` where status='Sent'""", as_dict=1
)
@ -325,3 +327,50 @@ class TestVerifiedRequests(FrappeTestCase):
set_request(method="GET", query_string=signed_url)
self.assertTrue(verify_request())
frappe.local.request = None
class TestEmailIntegrationTest(FrappeTestCase):
"""Sends email to local SMTP server and verifies correctness.
SMTP4Dev runs as a service in unit test CI job.
If you need to run this test locally, you must setup SMTP4dev locally.
WARNING: SMTP4dev doesn't have stable API, it can break anytime.
"""
SMTP4DEV_WEB = "http://localhost:3000"
def setUp(self) -> None:
# Frappe code is configured to not attempting sending emails during test.
frappe.flags.testing_email = True
requests.delete(f"{self.SMTP4DEV_WEB}/api/Messages/*")
return super().setUp()
def tearDown(self) -> None:
frappe.flags.testing_email = False
return super().tearDown()
def get_last_sent_emails(self):
return requests.get(
f"{self.SMTP4DEV_WEB}/api/Messages?sortColumn=receivedDate&sortIsDescending=true"
).json()
def test_send_email(self):
sender = "a@example.io"
recipients = "b@example.io,c@example.io"
subject = "checking if email works"
content = "is email working?"
frappe.sendmail(sender=sender, recipients=recipients, subject=subject, content=content, now=True)
email = frappe.get_last_doc("Email Queue")
self.assertEqual(email.sender, sender)
self.assertEqual(len(email.recipients), 2)
self.assertEqual(email.status, "Sent")
sent_mails = self.get_last_sent_emails()
self.assertEqual(len(sent_mails), 2)
for sent_mail in sent_mails:
self.assertEqual(sent_mail["from"], sender)
self.assertEqual(sent_mail["subject"], subject)
self.assertSetEqual(set(recipients.split(",")), {m["to"] for m in sent_mails})

View file

@ -196,7 +196,7 @@ class TestFrappeClient(FrappeTestCase):
api_secret = "ksk&93nxoe3os"
header = {"Authorization": f"token {api_key}:{api_secret}"}
res = requests.post(get_url() + "/api/method/frappe.auth.get_logged_user", headers=header)
self.assertEqual(res.status_code, 403)
self.assertEqual(res.status_code, 401)
# random api key and api secret
api_key = "@3djdk3kld"

View file

@ -7,6 +7,7 @@
Translation tools for frappe
"""
import functools
import io
import itertools
@ -14,11 +15,9 @@ import json
import operator
import os
import re
from contextlib import contextmanager
from contextlib import contextmanager, suppress
from csv import reader, writer
from pypika.terms import PseudoColumn
import frappe
from frappe.model.utils import InvalidIncludePath, render_include
from frappe.query_builder import DocType, Field
@ -159,81 +158,10 @@ def get_lang_dict():
)
def get_dict(fortype: str, name: str | None = None) -> dict[str, str]:
"""Returns translation dict for a type of object.
:param fortype: must be one of `doctype`, `page`, `report`, `include`, `jsfile`, `boot`
:param name: name of the document for which assets are to be returned.
"""
fortype = fortype.lower()
asset_key = fortype + ":" + (name or "-")
translation_assets = frappe.cache.hget("translation_assets", frappe.local.lang) or {}
if asset_key not in translation_assets:
messages = []
if fortype == "doctype":
messages = get_messages_from_doctype(name)
elif fortype == "page":
messages = get_messages_from_page(name)
elif fortype == "report":
messages = get_messages_from_report(name)
elif fortype == "include":
messages = get_messages_from_include_files()
elif fortype == "jsfile":
messages = get_messages_from_file(name)
elif fortype == "boot":
apps = frappe.get_all_apps(True)
for app in apps:
messages.extend(get_server_messages(app))
messages += get_messages_from_navbar()
messages += get_messages_from_include_files()
messages += (
frappe.qb.from_("Print Format").select(PseudoColumn("'Print Format:'"), "name")
).run()
messages += (frappe.qb.from_("DocType").select(PseudoColumn("'DocType:'"), "name")).run()
messages += frappe.qb.from_("Role").select(PseudoColumn("'Role:'"), "name").run()
messages += (frappe.qb.from_("Module Def").select(PseudoColumn("'Module:'"), "name")).run()
messages += (
frappe.qb.from_("Workspace Shortcut")
.where(Field("format").isnotnull())
.select(PseudoColumn("''"), "format")
).run()
messages += (frappe.qb.from_("Onboarding Step").select(PseudoColumn("''"), "title")).run()
messages = deduplicate_messages(messages)
message_dict = make_dict_from_messages(messages, load_user_translation=False)
message_dict.update(get_dict_from_hooks(fortype, name))
# remove untranslated
message_dict = {k: v for k, v in message_dict.items() if k != v}
translation_assets[asset_key] = message_dict
frappe.cache.hset("translation_assets", frappe.local.lang, translation_assets)
translation_map: dict = translation_assets[asset_key]
translation_map.update(get_user_translations(frappe.local.lang))
return translation_map
def get_messages_for_boot():
"""Return all message translations that are required on boot."""
messages = get_all_translations(frappe.local.lang)
messages.update(get_dict_from_hooks("boot", None))
return messages
def get_dict_from_hooks(fortype, name):
translated_dict = {}
hooks = frappe.get_hooks("get_translated_dict")
for (hook_fortype, fortype_name) in hooks:
if hook_fortype == fortype and fortype_name == name:
for method in hooks[(hook_fortype, fortype_name)]:
translated_dict.update(frappe.get_attr(method)())
return translated_dict
return get_all_translations(frappe.local.lang)
def make_dict_from_messages(messages, full_dict=None, load_user_translation=True):
@ -260,15 +188,6 @@ def make_dict_from_messages(messages, full_dict=None, load_user_translation=True
return out
def get_lang_js(fortype: str, name: str) -> str:
"""Returns code snippet to be appended at the end of a JS script.
:param fortype: Type of object, e.g. `DocType`
:param name: Document name
"""
return f"\n\n$.extend(frappe._messages, {json.dumps(get_dict(fortype, name))})"
def get_all_translations(lang: str) -> dict[str, str]:
"""Load and return the entire translations dictionary for a language from apps + user translations.
@ -278,13 +197,12 @@ def get_all_translations(lang: str) -> dict[str, str]:
return {}
def _merge_translations():
from frappe.geo.country_info import get_translated_countries
all_translations = get_translations_from_apps(lang).copy()
try:
# get user specific translation data
user_translations = get_user_translations(lang)
all_translations.update(user_translations)
except Exception:
pass
with suppress(Exception):
all_translations.update(get_user_translations(lang))
all_translations.update(get_translated_countries())
return all_translations

View file

@ -21,7 +21,7 @@ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fi
import frappe
import frappe.monitor
from frappe import _
from frappe.utils import cint, cstr, get_bench_id
from frappe.utils import CallbackManager, cint, cstr, get_bench_id
from frappe.utils.commands import log
from frappe.utils.deprecations import deprecation_warning
from frappe.utils.redis_queue import RedisQueue
@ -185,6 +185,7 @@ def run_doc_method(doctype, name, doc_method, **kwargs):
def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, retry=0):
"""Executes job in a worker, performs commit/rollback and logs if there is any error"""
retval = None
if is_async:
frappe.connect(site)
if os.environ.get("CI"):
@ -199,6 +200,15 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True,
else:
method_name = cstr(method.__name__)
frappe.local.job = frappe._dict(
site=site,
method=method_name,
job_name=job_name,
kwargs=kwargs,
user=user,
after_job=CallbackManager(),
)
for before_job_task in frappe.get_hooks("before_job"):
frappe.call(before_job_task, method=method_name, kwargs=kwargs, transaction_type="job")
@ -239,6 +249,7 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True,
finally:
for after_job_task in frappe.get_hooks("after_job"):
frappe.call(after_job_task, method=method_name, kwargs=kwargs, result=retval)
frappe.local.job.after_job.run()
if is_async:
frappe.destroy()

View file

@ -336,7 +336,7 @@ class BackupGenerator:
for _type, info in backup_summary.items():
template = f"{{0:{title}}}: {{1:{path}}} {{2}}"
print(template.format(_type.title(), info["path"], info["size"]))
print(template.format(_type.title(), os.path.abspath(info["path"]), info["size"]))
def backup_files(self):
for folder in ("public", "private"):

View file

@ -373,11 +373,53 @@ def sync_global_search():
:param flags:
:return:
"""
while frappe.cache.llen("global_search_queue") > 0:
# rpop to follow FIFO
# Last one should override all previous contents of same document
value = json.loads(frappe.cache.rpop("global_search_queue").decode("utf-8"))
sync_value(value)
from itertools import islice
def get_search_queue_item_generator():
while value := frappe.cache.rpop("global_search_queue"):
yield value
item_generator = get_search_queue_item_generator()
while search_items := tuple(islice(item_generator, 10_000)):
values = _get_deduped_search_item_values(search_items)
sync_values(values)
def _get_deduped_search_item_values(items):
from collections import OrderedDict
values_dict = OrderedDict()
for item in items:
item_json = item.decode("utf-8")
item_dict = json.loads(item_json)
key = (item_dict["doctype"], item_dict["name"])
values_dict[key] = tuple(item_dict.values())
return values_dict.values()
def sync_values(values: list):
from pypika.terms import Values
GlobalSearch = frappe.qb.Table("__global_search")
conflict_fields = ["content", "published", "title", "route"]
query = (
frappe.qb.into(GlobalSearch).columns(["doctype", "name"] + conflict_fields).insert(*values)
)
if frappe.db.db_type == "postgres":
query = query.on_conflict(GlobalSearch.doctype, GlobalSearch.name)
for field in conflict_fields:
if frappe.db.db_type == "mariadb":
query = query.on_duplicate_key_update(GlobalSearch[field], Values(field))
elif frappe.db.db_type == "postgres":
query = query.do_update(GlobalSearch[field])
else:
raise NotImplementedError
query.run()
def sync_value_in_queue(value):
@ -389,7 +431,7 @@ def sync_value_in_queue(value):
sync_value(value)
def sync_value(value):
def sync_value(value: dict):
"""
Sync a given document to global search
:param value: dict of { doctype, name, content, published, title, route }
@ -446,7 +488,9 @@ def search(text, start=0, limit=20, doctype=""):
results = []
sorted_results = []
allowed_doctypes = get_doctypes_for_global_search()
allowed_doctypes = set(get_doctypes_for_global_search()) & set(frappe.get_user().get_can_read())
if not allowed_doctypes or (doctype and doctype not in allowed_doctypes):
return []
for word in set(text.split("&")):
word = word.strip()
@ -464,7 +508,7 @@ def search(text, start=0, limit=20, doctype=""):
if doctype:
query = query.where(global_search.doctype == doctype)
elif allowed_doctypes:
else:
query = query.where(global_search.doctype.isin(allowed_doctypes))
if cint(start) > 0:

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