Merge branch 'develop' into po-translation
This commit is contained in:
commit
67404e0cd0
110 changed files with 1795 additions and 702 deletions
3
.github/helper/db/mariadb.json
vendored
3
.github/helper/db/mariadb.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
3
.github/helper/db/postgres.json
vendored
3
.github/helper/db/postgres.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
6
.github/workflows/server-tests.yml
vendored
6
.github/workflows/server-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("~")) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
frappe/core/doctype/data_import/patches/__init__.py
Normal file
0
frappe/core/doctype/data_import/patches/__init__.py
Normal 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"})
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
122
frappe/core/doctype/doctype/doctype_list.js
Normal file
122
frappe/core/doctype/doctype/doctype_list.js
Normal 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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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 [" ", "-", "(", ")"]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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()",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
/* This file is depricated use Inter.scss instead. */
|
||||
/* Backward compatibility */
|
||||
@font-face {
|
||||
font-family: 'Inter V';
|
||||
font-weight: 100 900;
|
||||
|
|
|
|||
164
frappe/public/css/fonts/inter/inter.scss
Normal file
164
frappe/public/css/fonts/inter/inter.scss
Normal 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");
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
139
frappe/public/js/form_builder/components/AddFieldButton.vue
Normal file
139
frappe/public/js/form_builder/components/AddFieldButton.vue
Normal 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>
|
||||
159
frappe/public/js/form_builder/components/Autocomplete.vue
Normal file
159
frappe/public/js/form_builder/components/Autocomplete.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -349,6 +349,13 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.editable-row {
|
||||
.markdown-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin base-grid() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@
|
|||
margin-bottom: 3rem;
|
||||
margin-top: 5rem;
|
||||
}
|
||||
a {
|
||||
.from-markdown a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue