@@ -128,6 +128,7 @@ jobs: | |||||
DB: mariadb | DB: mariadb | ||||
- name: Verify yarn.lock | - name: Verify yarn.lock | ||||
if: ${{ steps.check-build.outputs.build == 'strawberry' }} | |||||
run: | | run: | | ||||
cd ~/frappe-bench/apps/frappe | cd ~/frappe-bench/apps/frappe | ||||
yarn install --immutable --immutable-cache --check-cache | yarn install --immutable --immutable-cache --check-cache | ||||
@@ -819,9 +819,16 @@ def run_tests( | |||||
@click.option("--total-builds", help="Total number of builds", default=1) | @click.option("--total-builds", help="Total number of builds", default=1) | ||||
@click.option("--with-coverage", is_flag=True, help="Build coverage file") | @click.option("--with-coverage", is_flag=True, help="Build coverage file") | ||||
@click.option("--use-orchestrator", is_flag=True, help="Use orchestrator to run parallel tests") | @click.option("--use-orchestrator", is_flag=True, help="Use orchestrator to run parallel tests") | ||||
@click.option("--dry-run", is_flag=True, default=False, help="Dont actually run tests") | |||||
@pass_context | @pass_context | ||||
def run_parallel_tests( | def run_parallel_tests( | ||||
context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False | |||||
context, | |||||
app, | |||||
build_number, | |||||
total_builds, | |||||
with_coverage=False, | |||||
use_orchestrator=False, | |||||
dry_run=False, | |||||
): | ): | ||||
with CodeCoverage(with_coverage, app): | with CodeCoverage(with_coverage, app): | ||||
site = get_site(context) | site = get_site(context) | ||||
@@ -832,7 +839,13 @@ def run_parallel_tests( | |||||
else: | else: | ||||
from frappe.parallel_test_runner import ParallelTestRunner | from frappe.parallel_test_runner import ParallelTestRunner | ||||
ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds) | |||||
ParallelTestRunner( | |||||
app, | |||||
site=site, | |||||
build_number=build_number, | |||||
total_builds=total_builds, | |||||
dry_run=dry_run, | |||||
) | |||||
@click.command( | @click.command( | ||||
@@ -228,11 +228,12 @@ def get_company_address(company): | |||||
def address_query(doctype, txt, searchfield, start, page_len, filters): | def address_query(doctype, txt, searchfield, start, page_len, filters): | ||||
from frappe.desk.reportview import get_match_cond | from frappe.desk.reportview import get_match_cond | ||||
doctype = "Address" | |||||
link_doctype = filters.pop("link_doctype") | link_doctype = filters.pop("link_doctype") | ||||
link_name = filters.pop("link_name") | link_name = filters.pop("link_name") | ||||
condition = "" | condition = "" | ||||
meta = frappe.get_meta("Address") | |||||
meta = frappe.get_meta(doctype) | |||||
for fieldname, value in filters.items(): | for fieldname, value in filters.items(): | ||||
if meta.get_field(fieldname) or fieldname in frappe.db.DEFAULT_COLUMNS: | if meta.get_field(fieldname) or fieldname in frappe.db.DEFAULT_COLUMNS: | ||||
condition += f" and {fieldname}={frappe.db.escape(value)}" | condition += f" and {fieldname}={frappe.db.escape(value)}" | ||||
@@ -210,8 +210,9 @@ def update_contact(doc, method): | |||||
def contact_query(doctype, txt, searchfield, start, page_len, filters): | def contact_query(doctype, txt, searchfield, start, page_len, filters): | ||||
from frappe.desk.reportview import get_match_cond | from frappe.desk.reportview import get_match_cond | ||||
doctype = "Contact" | |||||
if ( | if ( | ||||
not frappe.get_meta("Contact").get_field(searchfield) | |||||
not frappe.get_meta(doctype).get_field(searchfield) | |||||
and searchfield not in frappe.db.DEFAULT_COLUMNS | and searchfield not in frappe.db.DEFAULT_COLUMNS | ||||
): | ): | ||||
return [] | return [] | ||||
@@ -572,12 +572,15 @@ class ImportFile: | |||||
###### | ###### | ||||
def read_file(self, file_path): | |||||
def read_file(self, file_path: str): | |||||
extn = os.path.splitext(file_path)[1][1:] | extn = os.path.splitext(file_path)[1][1:] | ||||
file_content = None | file_content = None | ||||
with open(file_path, mode="rb") as f: | |||||
file_content = f.read() | |||||
file_name = frappe.db.get_value("File", {"file_url": file_path}) | |||||
if file_name: | |||||
file = frappe.get_doc("File", file_name) | |||||
file_content = file.get_content() | |||||
return file_content, extn | return file_content, extn | ||||
@@ -320,7 +320,8 @@ | |||||
"depends_on": "eval:!doc.istable", | "depends_on": "eval:!doc.istable", | ||||
"fieldname": "title_field", | "fieldname": "title_field", | ||||
"fieldtype": "Data", | "fieldtype": "Data", | ||||
"label": "Title Field" | |||||
"label": "Title Field", | |||||
"mandatory_depends_on": "eval:doc.show_title_field_in_link" | |||||
}, | }, | ||||
{ | { | ||||
"depends_on": "eval:!doc.istable", | "depends_on": "eval:!doc.istable", | ||||
@@ -687,7 +688,7 @@ | |||||
"link_fieldname": "reference_doctype" | "link_fieldname": "reference_doctype" | ||||
} | } | ||||
], | ], | ||||
"modified": "2022-08-24 06:42:27.779699", | |||||
"modified": "2022-09-02 12:05:59.589751", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Core", | "module": "Core", | ||||
"name": "DocType", | "name": "DocType", | ||||
@@ -422,7 +422,6 @@ class File(Document): | |||||
return os.path.exists(self.get_full_path()) | return os.path.exists(self.get_full_path()) | ||||
def get_content(self) -> bytes: | def get_content(self) -> bytes: | ||||
"""Returns [`file_name`, `content`] for given file name `fname`""" | |||||
if self.is_folder: | if self.is_folder: | ||||
frappe.throw(_("Cannot get file contents of a Folder")) | frappe.throw(_("Cannot get file contents of a Folder")) | ||||
@@ -237,7 +237,7 @@ class User(Document): | |||||
) | ) | ||||
def share_with_self(self): | def share_with_self(self): | ||||
frappe.share.add( | |||||
frappe.share.add_docshare( | |||||
self.doctype, self.name, self.name, write=1, share=1, flags={"ignore_share_permission": True} | self.doctype, self.name, self.name, write=1, share=1, flags={"ignore_share_permission": True} | ||||
) | ) | ||||
@@ -901,6 +901,7 @@ def reset_password(user): | |||||
def user_query(doctype, txt, searchfield, start, page_len, filters): | def user_query(doctype, txt, searchfield, start, page_len, filters): | ||||
from frappe.desk.reportview import get_filters_cond, get_match_cond | from frappe.desk.reportview import get_filters_cond, get_match_cond | ||||
doctype = "User" | |||||
conditions = [] | conditions = [] | ||||
user_type_condition = "and user_type != 'Website User'" | user_type_condition = "and user_type != 'Website User'" | ||||
@@ -102,6 +102,20 @@ class CustomField(Document): | |||||
# delete property setter entries | # delete property setter entries | ||||
frappe.db.delete("Property Setter", {"doc_type": self.dt, "field_name": self.fieldname}) | frappe.db.delete("Property Setter", {"doc_type": self.dt, "field_name": self.fieldname}) | ||||
# update doctype layouts | |||||
doctype_layouts = frappe.get_all( | |||||
"DocType Layout", filters={"document_type": self.dt}, pluck="name" | |||||
) | |||||
for layout in doctype_layouts: | |||||
layout_doc = frappe.get_doc("DocType Layout", layout) | |||||
for field in layout_doc.fields: | |||||
if field.fieldname == self.fieldname: | |||||
layout_doc.remove(field) | |||||
layout_doc.save() | |||||
break | |||||
frappe.clear_cache(doctype=self.dt) | frappe.clear_cache(doctype=self.dt) | ||||
def validate_insert_after(self, meta): | def validate_insert_after(self, meta): | ||||
@@ -2,31 +2,104 @@ | |||||
// For license information, please see license.txt | // For license information, please see license.txt | ||||
frappe.ui.form.on("DocType Layout", { | frappe.ui.form.on("DocType Layout", { | ||||
refresh: function (frm) { | |||||
frm.trigger("document_type"); | |||||
frm.events.set_button(frm); | |||||
onload_post_render(frm) { | |||||
// disallow users from manually adding/deleting rows; this doctype should only | |||||
// be used for managing layout, and docfields and custom fields should be used | |||||
// to manage other field metadata (hidden, etc.) | |||||
frm.set_df_property("fields", "cannot_add_rows", true); | |||||
frm.set_df_property("fields", "cannot_delete_rows", true); | |||||
$(frm.wrapper).on("grid-move-row", (e, frm) => { | |||||
// refresh the layout after moving a row | |||||
frm.dirty(); | |||||
}); | |||||
}, | }, | ||||
document_type(frm) { | |||||
frm.set_fields_as_options("fields", frm.doc.document_type, null, [], "fieldname").then( | |||||
() => { | |||||
// child table empty? then show all fields as default | |||||
if (frm.doc.document_type) { | |||||
if (!(frm.doc.fields || []).length) { | |||||
for (let f of frappe.get_doc("DocType", frm.doc.document_type).fields) { | |||||
frm.add_child("fields", { fieldname: f.fieldname, label: f.label }); | |||||
} | |||||
} | |||||
} | |||||
refresh(frm) { | |||||
frm.events.add_buttons(frm); | |||||
}, | |||||
async document_type(frm) { | |||||
if (frm.doc.document_type) { | |||||
// refreshing the doctype fields resets the new name input field; | |||||
// once the fields are set, reset the name to the original input | |||||
if (frm.is_new()) { | |||||
const document_name = frm.doc.__newname || frm.doc.name; | |||||
} | |||||
frm.set_value("fields", []); | |||||
await frm.events.sync_fields(frm, false); | |||||
if (frm.is_new()) { | |||||
frm.doc.__newname = document_name; | |||||
frm.refresh_field("__newname"); | |||||
} | } | ||||
); | |||||
} | |||||
}, | }, | ||||
set_button(frm) { | |||||
add_buttons(frm) { | |||||
if (!frm.is_new()) { | if (!frm.is_new()) { | ||||
frm.add_custom_button(__("Go to {0} List", [frm.doc.name]), () => { | frm.add_custom_button(__("Go to {0} List", [frm.doc.name]), () => { | ||||
window.open(`/app/${frappe.router.slug(frm.doc.name)}`); | window.open(`/app/${frappe.router.slug(frm.doc.name)}`); | ||||
}); | }); | ||||
frm.add_custom_button(__("Sync {0} Fields", [frm.doc.name]), async () => { | |||||
await frm.events.sync_fields(frm, true); | |||||
}); | |||||
} | |||||
}, | |||||
async sync_fields(frm, notify) { | |||||
frappe.dom.freeze("Fetching fields..."); | |||||
const response = await frm.call({ doc: frm.doc, method: "sync_fields" }); | |||||
frm.refresh_field("fields"); | |||||
frappe.dom.unfreeze(); | |||||
if (!response.message) { | |||||
frappe.msgprint(__("No changes to sync")); | |||||
return; | |||||
} | |||||
frm.dirty(); | |||||
if (notify) { | |||||
const addedFields = response.message.added; | |||||
const removedFields = response.message.removed; | |||||
const getChangedMessage = (fields) => { | |||||
let changes = ""; | |||||
for (const field of fields) { | |||||
if (field.label) { | |||||
changes += `<li>Row #${field.idx}: ${field.fieldname.bold()} (${ | |||||
field.label | |||||
})</li>`; | |||||
} else { | |||||
changes += `<li>Row #${field.idx}: ${field.fieldname.bold()}</li>`; | |||||
} | |||||
} | |||||
return changes; | |||||
}; | |||||
let message = ""; | |||||
if (addedFields.length) { | |||||
message += `The following fields have been added:<br><br><ul>${getChangedMessage( | |||||
addedFields | |||||
)}</ul>`; | |||||
} | |||||
if (removedFields.length) { | |||||
message += `The following fields have been removed:<br><br><ul>${getChangedMessage( | |||||
removedFields | |||||
)}</ul>`; | |||||
} | |||||
if (message) { | |||||
frappe.msgprint({ | |||||
message: __(message), | |||||
indicator: "green", | |||||
title: __("Synced Fields"), | |||||
}); | |||||
} | |||||
} | } | ||||
}, | }, | ||||
}); | }); |
@@ -1,7 +1,7 @@ | |||||
{ | { | ||||
"actions": [], | "actions": [], | ||||
"allow_rename": 1, | "allow_rename": 1, | ||||
"autoname": "Prompt", | |||||
"autoname": "prompt", | |||||
"creation": "2020-11-16 17:05:35.306846", | "creation": "2020-11-16 17:05:35.306846", | ||||
"doctype": "DocType", | "doctype": "DocType", | ||||
"editable_grid": 1, | "editable_grid": 1, | ||||
@@ -19,7 +19,8 @@ | |||||
"in_list_view": 1, | "in_list_view": 1, | ||||
"label": "Document Type", | "label": "Document Type", | ||||
"options": "DocType", | "options": "DocType", | ||||
"reqd": 1 | |||||
"reqd": 1, | |||||
"set_only_once": 1 | |||||
}, | }, | ||||
{ | { | ||||
"fieldname": "fields", | "fieldname": "fields", | ||||
@@ -42,10 +43,11 @@ | |||||
], | ], | ||||
"index_web_pages_for_search": 1, | "index_web_pages_for_search": 1, | ||||
"links": [], | "links": [], | ||||
"modified": "2020-12-10 15:01:04.352184", | |||||
"modified": "2022-09-01 03:22:33.973058", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Custom", | "module": "Custom", | ||||
"name": "DocType Layout", | "name": "DocType Layout", | ||||
"naming_rule": "Set by user", | |||||
"owner": "Administrator", | "owner": "Administrator", | ||||
"permissions": [ | "permissions": [ | ||||
{ | { | ||||
@@ -68,5 +70,6 @@ | |||||
"route": "doctype-layout", | "route": "doctype-layout", | ||||
"sort_field": "modified", | "sort_field": "modified", | ||||
"sort_order": "DESC", | "sort_order": "DESC", | ||||
"states": [], | |||||
"track_changes": 1 | "track_changes": 1 | ||||
} | } |
@@ -1,11 +1,77 @@ | |||||
# Copyright (c) 2020, Frappe Technologies and contributors | # Copyright (c) 2020, Frappe Technologies and contributors | ||||
# License: MIT. See LICENSE | # License: MIT. See LICENSE | ||||
from typing import TYPE_CHECKING | |||||
import frappe | |||||
from frappe.desk.utils import slug | from frappe.desk.utils import slug | ||||
from frappe.model.document import Document | from frappe.model.document import Document | ||||
if TYPE_CHECKING: | |||||
from frappe.core.doctype.docfield.docfield import DocField | |||||
class DocTypeLayout(Document): | class DocTypeLayout(Document): | ||||
def validate(self): | def validate(self): | ||||
if not self.route: | if not self.route: | ||||
self.route = slug(self.name) | self.route = slug(self.name) | ||||
@frappe.whitelist() | |||||
def sync_fields(self): | |||||
doctype_fields = frappe.get_meta(self.document_type).fields | |||||
if self.is_new(): | |||||
added_fields = [field.fieldname for field in doctype_fields] | |||||
removed_fields = [] | |||||
else: | |||||
doctype_fieldnames = {field.fieldname for field in doctype_fields} | |||||
layout_fieldnames = {field.fieldname for field in self.fields} | |||||
added_fields = list(doctype_fieldnames - layout_fieldnames) | |||||
removed_fields = list(layout_fieldnames - doctype_fieldnames) | |||||
if not (added_fields or removed_fields): | |||||
return | |||||
added = self.add_fields(added_fields, doctype_fields) | |||||
removed = self.remove_fields(removed_fields) | |||||
for index, field in enumerate(self.fields): | |||||
field.idx = index + 1 | |||||
return {"added": added, "removed": removed} | |||||
def add_fields(self, added_fields: list[str], doctype_fields: list["DocField"]) -> list[dict]: | |||||
added = [] | |||||
for field in added_fields: | |||||
field_details = next((f for f in doctype_fields if f.fieldname == field), None) | |||||
if not field_details: | |||||
continue | |||||
# remove 'doctype' data from the DocField to allow adding it to the layout | |||||
row = self.append("fields", field_details.as_dict(no_default_fields=True)) | |||||
row_data = row.as_dict() | |||||
if field_details.get("insert_after"): | |||||
insert_after = next( | |||||
(f for f in self.fields if f.fieldname == field_details.insert_after), | |||||
None, | |||||
) | |||||
# initialize new row to just after the insert_after field | |||||
if insert_after: | |||||
self.fields.insert(insert_after.idx, row) | |||||
self.fields.pop() | |||||
row_data = {"idx": insert_after.idx + 1, "fieldname": row.fieldname, "label": row.label} | |||||
added.append(row_data) | |||||
return added | |||||
def remove_fields(self, removed_fields: list[str]) -> list[dict]: | |||||
removed = [] | |||||
for field in removed_fields: | |||||
field_details = next((f for f in self.fields if f.fieldname == field), None) | |||||
if field_details: | |||||
self.remove(field_details) | |||||
removed.append(field_details.as_dict()) | |||||
return removed |
@@ -27,7 +27,6 @@ from frappe.database.utils import ( | |||||
from frappe.exceptions import DoesNotExistError, ImplicitCommitError | from frappe.exceptions import DoesNotExistError, ImplicitCommitError | ||||
from frappe.model.utils.link_count import flush_local_link_count | from frappe.model.utils.link_count import flush_local_link_count | ||||
from frappe.query_builder.functions import Count | from frappe.query_builder.functions import Count | ||||
from frappe.query_builder.utils import DocType | |||||
from frappe.utils import cast as cast_fieldtype | from frappe.utils import cast as cast_fieldtype | ||||
from frappe.utils import get_datetime, get_table_name, getdate, now, sbool | from frappe.utils import get_datetime, get_table_name, getdate, now, sbool | ||||
@@ -857,7 +856,7 @@ class Database: | |||||
:param modified_by: Set this user as `modified_by`. | :param modified_by: Set this user as `modified_by`. | ||||
:param update_modified: default True. Set as false, if you don't want to update the timestamp. | :param update_modified: default True. Set as false, if you don't want to update the timestamp. | ||||
:param debug: Print the query in the developer / js console. | :param debug: Print the query in the developer / js console. | ||||
:param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit. | |||||
:param for_update: [DEPRECATED] This function now performs updates in single query, locking is not required. | |||||
""" | """ | ||||
is_single_doctype = not (dn and dt != dn) | is_single_doctype = not (dn and dt != dn) | ||||
to_update = field if isinstance(field, dict) else {field: val} | to_update = field if isinstance(field, dict) else {field: val} | ||||
@@ -879,19 +878,11 @@ class Database: | |||||
frappe.clear_document_cache(dt, dt) | frappe.clear_document_cache(dt, dt) | ||||
else: | else: | ||||
table = DocType(dt) | |||||
if for_update: | |||||
docnames = tuple( | |||||
self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True) | |||||
) or (NullValue(),) | |||||
query = frappe.qb.update(table).where(table.name.isin(docnames)) | |||||
for docname in docnames: | |||||
frappe.clear_document_cache(dt, docname) | |||||
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) | |||||
if isinstance(dn, str): | |||||
frappe.clear_document_cache(dt, dn) | |||||
else: | else: | ||||
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) | |||||
# TODO: Fix this; doesn't work rn - gavin@frappe.io | # TODO: Fix this; doesn't work rn - gavin@frappe.io | ||||
# frappe.cache().hdel_keys(dt, "document_cache") | # frappe.cache().hdel_keys(dt, "document_cache") | ||||
# Workaround: clear all document caches | # Workaround: clear all document caches | ||||
@@ -8,7 +8,6 @@ import re | |||||
import frappe | import frappe | ||||
from frappe import _, is_whitelisted | from frappe import _, is_whitelisted | ||||
from frappe.permissions import has_permission | from frappe.permissions import has_permission | ||||
from frappe.translate import get_translated_doctypes | |||||
from frappe.utils import cint, cstr, unique | from frappe.utils import cint, cstr, unique | ||||
@@ -150,10 +149,6 @@ def search_widget( | |||||
filters = [] | filters = [] | ||||
or_filters = [] | or_filters = [] | ||||
translated_doctypes = frappe.cache().hget( | |||||
"translated_doctypes", "doctypes", get_translated_doctypes | |||||
) | |||||
# build from doctype | # build from doctype | ||||
if txt: | if txt: | ||||
field_types = [ | field_types = [ | ||||
@@ -175,7 +170,7 @@ def search_widget( | |||||
for f in search_fields: | for f in search_fields: | ||||
fmeta = meta.get_field(f.strip()) | fmeta = meta.get_field(f.strip()) | ||||
if (doctype not in translated_doctypes) and ( | |||||
if not meta.translated_doctype and ( | |||||
f == "name" or (fmeta and fmeta.fieldtype in field_types) | f == "name" or (fmeta and fmeta.fieldtype in field_types) | ||||
): | ): | ||||
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"]) | or_filters.append([doctype, f.strip(), "like", f"%{txt}%"]) | ||||
@@ -191,26 +186,25 @@ def search_widget( | |||||
fields = list(set(fields + json.loads(filter_fields))) | fields = list(set(fields + json.loads(filter_fields))) | ||||
formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields] | formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields] | ||||
title_field_query = get_title_field_query(meta) | |||||
# Insert title field query after name | # Insert title field query after name | ||||
if title_field_query: | |||||
formatted_fields.insert(1, title_field_query) | |||||
# find relevance as location of search term from the beginning of string `name`. used for sorting results. | |||||
formatted_fields.append( | |||||
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( | |||||
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), | |||||
doctype=doctype, | |||||
) | |||||
) | |||||
if meta.show_title_field_in_link: | |||||
formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`") | |||||
# In order_by, `idx` gets second priority, because it stores link count | # In order_by, `idx` gets second priority, because it stores link count | ||||
from frappe.model.db_query import get_order_by | from frappe.model.db_query import get_order_by | ||||
order_by_based_on_meta = get_order_by(doctype, meta) | order_by_based_on_meta = get_order_by(doctype, meta) | ||||
# 2 is the index of _relevance column | # 2 is the index of _relevance column | ||||
order_by = f"_relevance, {order_by_based_on_meta}, `tab{doctype}`.idx desc" | |||||
order_by = f"{order_by_based_on_meta}, `tab{doctype}`.idx desc" | |||||
if not meta.translated_doctype: | |||||
formatted_fields.append( | |||||
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( | |||||
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), | |||||
doctype=doctype, | |||||
) | |||||
) | |||||
order_by = f"_relevance, {order_by}" | |||||
ptype = "select" if frappe.only_has_select_perm(doctype) else "read" | ptype = "select" if frappe.only_has_select_perm(doctype) else "read" | ||||
ignore_permissions = ( | ignore_permissions = ( | ||||
@@ -219,16 +213,13 @@ def search_widget( | |||||
else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype)) | else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype)) | ||||
) | ) | ||||
if doctype in translated_doctypes: | |||||
page_length = None | |||||
values = frappe.get_list( | values = frappe.get_list( | ||||
doctype, | doctype, | ||||
filters=filters, | filters=filters, | ||||
fields=formatted_fields, | fields=formatted_fields, | ||||
or_filters=or_filters, | or_filters=or_filters, | ||||
limit_start=start, | limit_start=start, | ||||
limit_page_length=page_length, | |||||
limit_page_length=None if meta.translated_doctype else page_length, | |||||
order_by=order_by, | order_by=order_by, | ||||
ignore_permissions=ignore_permissions, | ignore_permissions=ignore_permissions, | ||||
reference_doctype=reference_doctype, | reference_doctype=reference_doctype, | ||||
@@ -236,12 +227,15 @@ def search_widget( | |||||
strict=False, | strict=False, | ||||
) | ) | ||||
if doctype in translated_doctypes: | |||||
if meta.translated_doctype: | |||||
# Filtering the values array so that query is included in very element | # Filtering the values array so that query is included in very element | ||||
values = ( | values = ( | ||||
v | |||||
for v in values | |||||
if re.search(f"{re.escape(txt)}.*", _(v.name if as_dict else v[0]), re.IGNORECASE) | |||||
result | |||||
for result in values | |||||
if any( | |||||
re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE) | |||||
for value in (result.values() if as_dict else result) | |||||
) | |||||
) | ) | ||||
# Sorting the values array so that relevant results always come first | # Sorting the values array so that relevant results always come first | ||||
@@ -250,12 +244,14 @@ def search_widget( | |||||
values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict)) | values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict)) | ||||
# remove _relevance from results | # remove _relevance from results | ||||
if as_dict: | |||||
for r in values: | |||||
r.pop("_relevance") | |||||
frappe.response["values"] = values | |||||
else: | |||||
frappe.response["values"] = [r[:-1] for r in values] | |||||
if not meta.translated_doctype: | |||||
if as_dict: | |||||
for r in values: | |||||
r.pop("_relevance") | |||||
else: | |||||
values = [r[:-1] for r in values] | |||||
frappe.response["values"] = values | |||||
def get_std_fields_list(meta, key): | def get_std_fields_list(meta, key): | ||||
@@ -275,39 +271,23 @@ def get_std_fields_list(meta, key): | |||||
return sflist | return sflist | ||||
def get_title_field_query(meta): | |||||
title_field = meta.title_field if meta.title_field else None | |||||
show_title_field_in_link = ( | |||||
meta.show_title_field_in_link if meta.show_title_field_in_link else None | |||||
) | |||||
field = None | |||||
if title_field and show_title_field_in_link: | |||||
field = f"`tab{meta.name}`.{title_field} as `label`" | |||||
return field | |||||
def build_for_autosuggest(res: list[tuple], doctype: str) -> list[dict]: | |||||
def to_string(parts): | |||||
return ", ".join( | |||||
unique(_(cstr(part)) if meta.translated_doctype else cstr(part) for part in parts if part) | |||||
) | |||||
def build_for_autosuggest(res, doctype): | |||||
results = [] | results = [] | ||||
meta = frappe.get_meta(doctype) | meta = frappe.get_meta(doctype) | ||||
if not (meta.title_field and meta.show_title_field_in_link): | |||||
for r in res: | |||||
r = list(r) | |||||
results.append({"value": r[0], "description": ", ".join(unique(cstr(d) for d in r[1:] if d))}) | |||||
if meta.show_title_field_in_link: | |||||
for item in res: | |||||
item = list(item) | |||||
label = item[1] # use title as label | |||||
item[1] = item[0] # show name in description instead of title | |||||
del item[2] # remove redundant title ("label") value | |||||
results.append({"value": item[0], "label": label, "description": to_string(item[1:])}) | |||||
else: | else: | ||||
title_field_exists = meta.title_field and meta.show_title_field_in_link | |||||
_from = 2 if title_field_exists else 1 # to exclude title from description if title_field_exists | |||||
for r in res: | |||||
r = list(r) | |||||
results.append( | |||||
{ | |||||
"value": r[0], | |||||
"label": r[1] if title_field_exists else None, | |||||
"description": ", ".join(unique(cstr(d) for d in r[_from:] if d)), | |||||
} | |||||
) | |||||
results.extend({"value": item[0], "description": to_string(item[1:])} for item in res) | |||||
return results | return results | ||||
@@ -383,7 +363,7 @@ def get_user_groups(): | |||||
def get_link_title(doctype, docname): | def get_link_title(doctype, docname): | ||||
meta = frappe.get_meta(doctype) | meta = frappe.get_meta(doctype) | ||||
if meta.title_field and meta.show_title_field_in_link: | |||||
if meta.show_title_field_in_link: | |||||
return frappe.db.get_value(doctype, docname, meta.title_field) | return frappe.db.get_value(doctype, docname, meta.title_field) | ||||
return docname | return docname |
@@ -176,7 +176,7 @@ frappe.ui.form.on("Notification", { | |||||
}, | }, | ||||
callback: function (r) { | callback: function (r) { | ||||
if (r.message && r.message.length > 0) { | if (r.message && r.message.length > 0) { | ||||
frappe.msgprint(r.message); | |||||
frappe.msgprint(r.message.toString()); | |||||
} else { | } else { | ||||
frappe.msgprint(__("No alerts for today")); | frappe.msgprint(__("No alerts for today")); | ||||
} | } | ||||
@@ -44,44 +44,6 @@ class TestEventProducer(FrappeTestCase): | |||||
self.pull_producer_data() | self.pull_producer_data() | ||||
self.assertFalse(frappe.db.exists("ToDo", producer_doc.name)) | self.assertFalse(frappe.db.exists("ToDo", producer_doc.name)) | ||||
@run_only_if(db_type_is.MARIADB) | |||||
def test_multiple_doctypes_sync(self): | |||||
# TODO: This test is extremely flaky with Postgres. Rewrite this! | |||||
producer = get_remote_site() | |||||
# insert todo and note in producer | |||||
producer_todo = insert_into_producer(producer, "test multiple doc sync") | |||||
producer_note1 = frappe._dict(doctype="Note", title="test multiple doc sync 1") | |||||
delete_on_remote_if_exists(producer, "Note", {"title": producer_note1["title"]}) | |||||
frappe.db.delete("Note", {"title": producer_note1["title"]}) | |||||
producer_note1 = producer.insert(producer_note1) | |||||
producer_note2 = frappe._dict(doctype="Note", title="test multiple doc sync 2") | |||||
delete_on_remote_if_exists(producer, "Note", {"title": producer_note2["title"]}) | |||||
frappe.db.delete("Note", {"title": producer_note2["title"]}) | |||||
producer_note2 = producer.insert(producer_note2) | |||||
# update in producer | |||||
producer_todo["description"] = "test multiple doc update sync" | |||||
producer_todo = producer.update(producer_todo) | |||||
producer_note1["content"] = "testing update sync" | |||||
producer_note1 = producer.update(producer_note1) | |||||
producer.delete("Note", producer_note2.name) | |||||
self.pull_producer_data() | |||||
# check inserted | |||||
self.assertTrue(frappe.db.exists("ToDo", producer_todo.name)) | |||||
# check update | |||||
local_todo = frappe.get_doc("ToDo", producer_todo.name) | |||||
self.assertEqual(local_todo.description, producer_todo.description) | |||||
local_note1 = frappe.get_doc("Note", producer_note1.name) | |||||
self.assertEqual(local_note1.content, producer_note1.content) | |||||
# check delete | |||||
self.assertFalse(frappe.db.exists("Note", producer_note2.name)) | |||||
def test_child_table_sync_with_dependencies(self): | def test_child_table_sync_with_dependencies(self): | ||||
producer = get_remote_site() | producer = get_remote_site() | ||||
producer_user = frappe._dict( | producer_user = frappe._dict( | ||||
@@ -517,10 +517,10 @@ def google_calendar_to_repeat_on(start, end, recurrence=None): | |||||
repeat_on = { | repeat_on = { | ||||
"starts_on": get_datetime(start.get("date")) | "starts_on": get_datetime(start.get("date")) | ||||
if start.get("date") | if start.get("date") | ||||
else parser.parse(start.get("dateTime")).utcnow(), | |||||
else parser.parse(start.get("dateTime")).astimezone().replace(tzinfo=None), | |||||
"ends_on": get_datetime(end.get("date")) | "ends_on": get_datetime(end.get("date")) | ||||
if end.get("date") | if end.get("date") | ||||
else parser.parse(end.get("dateTime")).utcnow(), | |||||
else parser.parse(end.get("dateTime")).astimezone().replace(tzinfo=None), | |||||
"all_day": 1 if start.get("date") else 0, | "all_day": 1 if start.get("date") else 0, | ||||
"repeat_this_event": 1 if recurrence else 0, | "repeat_this_event": 1 if recurrence else 0, | ||||
"repeat_on": None, | "repeat_on": None, | ||||
@@ -18,11 +18,12 @@ if click_ctx: | |||||
class ParallelTestRunner: | class ParallelTestRunner: | ||||
def __init__(self, app, site, build_number=1, total_builds=1): | |||||
def __init__(self, app, site, build_number=1, total_builds=1, dry_run=False): | |||||
self.app = app | self.app = app | ||||
self.site = site | self.site = site | ||||
self.build_number = frappe.utils.cint(build_number) or 1 | self.build_number = frappe.utils.cint(build_number) or 1 | ||||
self.total_builds = frappe.utils.cint(total_builds) | self.total_builds = frappe.utils.cint(total_builds) | ||||
self.dry_run = dry_run | |||||
self.setup_test_site() | self.setup_test_site() | ||||
self.run_tests() | self.run_tests() | ||||
@@ -31,6 +32,9 @@ class ParallelTestRunner: | |||||
if not frappe.db: | if not frappe.db: | ||||
frappe.connect() | frappe.connect() | ||||
if self.dry_run: | |||||
return | |||||
frappe.flags.in_test = True | frappe.flags.in_test = True | ||||
frappe.clear_cache() | frappe.clear_cache() | ||||
frappe.utils.scheduler.disable_scheduler() | frappe.utils.scheduler.disable_scheduler() | ||||
@@ -64,6 +68,10 @@ class ParallelTestRunner: | |||||
if not file_info: | if not file_info: | ||||
return | return | ||||
if self.dry_run: | |||||
print("running tests from", "/".join(file_info)) | |||||
return | |||||
frappe.set_user("Administrator") | frappe.set_user("Administrator") | ||||
path, filename = file_info | path, filename = file_info | ||||
module = self.get_module(path, filename) | module = self.get_module(path, filename) | ||||
@@ -108,12 +116,48 @@ class ParallelTestRunner: | |||||
sys.exit(1) | sys.exit(1) | ||||
def get_test_file_list(self): | def get_test_file_list(self): | ||||
# Load balance based on total # of tests ~ each runner should get roughly same # of tests. | |||||
test_list = get_all_tests(self.app) | test_list = get_all_tests(self.app) | ||||
split_size = frappe.utils.ceil(len(test_list) / self.total_builds) | |||||
# [1,2,3,4,5,6] to [[1,2], [3,4], [4,6]] if split_size is 2 | |||||
test_chunks = [test_list[x : x + split_size] for x in range(0, len(test_list), split_size)] | |||||
test_counts = [self.get_test_count(test) for test in test_list] | |||||
test_chunks = split_by_weight(test_list, test_counts, chunk_count=self.total_builds) | |||||
return test_chunks[self.build_number - 1] | return test_chunks[self.build_number - 1] | ||||
@staticmethod | |||||
def get_test_count(test): | |||||
"""Get approximate count of tests inside a file""" | |||||
file_name = "/".join(test) | |||||
with open(file_name) as f: | |||||
test_count = f.read().count("def test_") | |||||
return test_count | |||||
def split_by_weight(work, weights, chunk_count): | |||||
"""Roughly split work by respective weight while keep ordering.""" | |||||
expected_weight = sum(weights) // chunk_count | |||||
chunks = [[] for _ in range(chunk_count)] | |||||
chunk_no = 0 | |||||
chunk_weight = 0 | |||||
for task, weight in zip(work, weights): | |||||
if chunk_weight > expected_weight: | |||||
chunk_weight = 0 | |||||
chunk_no += 1 | |||||
assert chunk_no < chunk_count | |||||
chunks[chunk_no].append(task) | |||||
chunk_weight += weight | |||||
assert len(work) == sum(len(chunk) for chunk in chunks) | |||||
assert len(chunks) == chunk_count | |||||
return chunks | |||||
class ParallelTestResult(unittest.TextTestResult): | class ParallelTestResult(unittest.TextTestResult): | ||||
def startTest(self, test): | def startTest(self, test): | ||||
@@ -89,10 +89,13 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat | |||||
is_translatable() { | is_translatable() { | ||||
return in_list(frappe.boot?.translated_doctypes || [], this.get_options()); | return in_list(frappe.boot?.translated_doctypes || [], this.get_options()); | ||||
} | } | ||||
is_title_link() { | |||||
return in_list(frappe.boot.link_title_doctypes, this.get_options()); | |||||
} | |||||
async set_link_title(value) { | async set_link_title(value) { | ||||
const doctype = this.get_options(); | const doctype = this.get_options(); | ||||
if (!doctype || !in_list(frappe.boot.link_title_doctypes, doctype)) { | |||||
if (!doctype || !this.is_title_link()) { | |||||
this.translate_and_set_input_value(value, value); | this.translate_and_set_input_value(value, value); | ||||
return; | return; | ||||
} | } | ||||
@@ -207,7 +210,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat | |||||
let _label = me.get_translated(d.label); | let _label = me.get_translated(d.label); | ||||
let html = d.html || "<strong>" + _label + "</strong>"; | let html = d.html || "<strong>" + _label + "</strong>"; | ||||
if (d.description && d.value !== d.description) { | |||||
if ( | |||||
d.description && | |||||
// for title links, we want to inlude the value in the description | |||||
// because it will not visible otherwise | |||||
(me.is_title_link() || d.value !== d.description) | |||||
) { | |||||
html += '<br><span class="small">' + __(d.description) + "</span>"; | html += '<br><span class="small">' + __(d.description) + "</span>"; | ||||
} | } | ||||
return $("<li></li>") | return $("<li></li>") | ||||
@@ -52,9 +52,7 @@ export default class Grid { | |||||
} | } | ||||
allow_on_grid_editing() { | allow_on_grid_editing() { | ||||
if (frappe.utils.is_xs()) { | |||||
return false; | |||||
} else if ((this.meta && this.meta.editable_grid) || !this.meta) { | |||||
if ((this.meta && this.meta.editable_grid) || !this.meta) { | |||||
return true; | return true; | ||||
} else { | } else { | ||||
return false; | return false; | ||||
@@ -66,17 +64,19 @@ export default class Grid { | |||||
<label class="control-label">${__(this.df.label || "")}</label> | <label class="control-label">${__(this.df.label || "")}</label> | ||||
<p class="text-muted small grid-description"></p> | <p class="text-muted small grid-description"></p> | ||||
<div class="grid-custom-buttons grid-field"></div> | <div class="grid-custom-buttons grid-field"></div> | ||||
<div class="form-grid"> | |||||
<div class="grid-heading-row"></div> | |||||
<div class="grid-body"> | |||||
<div class="rows"></div> | |||||
<div class="grid-empty text-center"> | |||||
<img | |||||
src="/assets/frappe/images/ui-states/grid-empty-state.svg" | |||||
alt="Grid Empty State" | |||||
class="grid-empty-illustration" | |||||
> | |||||
${__("No Data")} | |||||
<div class="form-grid-container"> | |||||
<div class="form-grid"> | |||||
<div class="grid-heading-row"></div> | |||||
<div class="grid-body"> | |||||
<div class="rows"></div> | |||||
<div class="grid-empty text-center"> | |||||
<img | |||||
src="/assets/frappe/images/ui-states/grid-empty-state.svg" | |||||
alt="Grid Empty State" | |||||
class="grid-empty-illustration" | |||||
> | |||||
${__("No Data")} | |||||
</div> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -1011,6 +1011,7 @@ export default class Grid { | |||||
Int: (val) => cint(val), | Int: (val) => cint(val), | ||||
Check: (val) => cint(val), | Check: (val) => cint(val), | ||||
Float: (val) => flt(val), | Float: (val) => flt(val), | ||||
Currency: (val) => flt(val), | |||||
}; | }; | ||||
// upload | // upload | ||||
@@ -254,7 +254,7 @@ export default class GridRow { | |||||
).appendTo(this.row); | ).appendTo(this.row); | ||||
this.row_index = $( | this.row_index = $( | ||||
`<div class="row-index sortable-handle col hidden-xs"> | |||||
`<div class="row-index sortable-handle col"> | |||||
<span>${txt}</span> | <span>${txt}</span> | ||||
</div>` | </div>` | ||||
) | ) | ||||
@@ -268,7 +268,7 @@ export default class GridRow { | |||||
this.row_check = $(`<div class="row-check col search"></div>`).appendTo(this.row); | this.row_check = $(`<div class="row-check col search"></div>`).appendTo(this.row); | ||||
this.row_index = $( | this.row_index = $( | ||||
`<div class="row-index col search hidden-xs"> | |||||
`<div class="row-index col search"> | |||||
<input type="text" class="form-control input-xs text-center" > | <input type="text" class="form-control input-xs text-center" > | ||||
</div>` | </div>` | ||||
).appendTo(this.row); | ).appendTo(this.row); | ||||
@@ -327,7 +327,7 @@ export default class GridRow { | |||||
if (this.doc && !this.grid.df.in_place_edit) { | if (this.doc && !this.grid.df.in_place_edit) { | ||||
// remove row | // remove row | ||||
if (!this.open_form_button) { | if (!this.open_form_button) { | ||||
this.open_form_button = $('<div class="col col-xs-1"></div>').appendTo(this.row); | |||||
this.open_form_button = $('<div class="col"></div>').appendTo(this.row); | |||||
if (!this.configure_columns) { | if (!this.configure_columns) { | ||||
this.open_form_button = $(` | this.open_form_button = $(` | ||||
@@ -356,7 +356,7 @@ export default class GridRow { | |||||
if (this.configure_columns && this.frm) { | if (this.configure_columns && this.frm) { | ||||
this.configure_columns_button = $(` | this.configure_columns_button = $(` | ||||
<div class="col grid-static-col col-xs-1 d-flex justify-content-center" style="cursor: pointer;"> | |||||
<div class="col grid-static-col d-flex justify-content-center" style="cursor: pointer;"> | |||||
<a>${frappe.utils.icon("setting-gear", "sm", "", "filter: opacity(0.5)")}</a> | <a>${frappe.utils.icon("setting-gear", "sm", "", "filter: opacity(0.5)")}</a> | ||||
</div> | </div> | ||||
`) | `) | ||||
@@ -366,7 +366,7 @@ export default class GridRow { | |||||
}); | }); | ||||
} else if (this.configure_columns && !this.frm) { | } else if (this.configure_columns && !this.frm) { | ||||
this.configure_columns_button = $(` | this.configure_columns_button = $(` | ||||
<div class="col grid-static-col col-xs-1"></div> | |||||
<div class="col grid-static-col"></div> | |||||
`).appendTo(this.row); | `).appendTo(this.row); | ||||
} | } | ||||
} | } | ||||
@@ -688,7 +688,7 @@ export default class GridRow { | |||||
if (this.show_search) { | if (this.show_search) { | ||||
// last empty column | // last empty column | ||||
$(`<div class="col grid-static-col col-xs-1"></div>`).appendTo(this.row); | |||||
$(`<div class="col grid-static-col search"></div>`).appendTo(this.row); | |||||
} | } | ||||
} | } | ||||
@@ -835,6 +835,60 @@ export default class GridRow { | |||||
: ""; | : ""; | ||||
add_class += ["Check"].indexOf(df.fieldtype) !== -1 ? " text-center" : ""; | add_class += ["Check"].indexOf(df.fieldtype) !== -1 ? " text-center" : ""; | ||||
let grid; | |||||
let grid_container; | |||||
let inital_position_x = 0; | |||||
let start_x = 0; | |||||
let start_y = 0; | |||||
let input_in_focus = false; | |||||
let vertical = false; | |||||
let horizontal = false; | |||||
// prevent random layout shifts caused by widgets and on click position elements inside view (UX). | |||||
function on_input_focus(el) { | |||||
input_in_focus = true; | |||||
let container_width = grid_container.getBoundingClientRect().width; | |||||
let container_left = grid_container.getBoundingClientRect().left; | |||||
let grid_left = parseFloat(grid.style.left); | |||||
let element_left = el.offset().left; | |||||
let fieldtype = el.data("fieldtype"); | |||||
let offset_right = container_width - (element_left + el.width()); | |||||
let offset_left = 0; | |||||
let element_screen_x = element_left - container_left; | |||||
let element_position_x = container_width - (element_left - container_left); | |||||
if (["Date", "Time", "Datetime"].includes(fieldtype)) { | |||||
offset_left = element_position_x - 220; | |||||
} | |||||
if (["Link", "Dynamic Link"].includes(fieldtype)) { | |||||
offset_left = element_position_x - 250; | |||||
} | |||||
if (element_screen_x < 0) { | |||||
grid.style.left = `${grid_left - element_screen_x}px`; | |||||
} else if (offset_left < 0) { | |||||
grid.style.left = `${grid_left + offset_left}px`; | |||||
} else if (offset_right < 0) { | |||||
grid.style.left = `${grid_left + offset_right}px`; | |||||
} | |||||
} | |||||
// Delay date_picker widget to prevent temparary layout shift (UX). | |||||
function handle_date_picker() { | |||||
let date_time_picker = document.querySelectorAll(".datepicker.active")[0]; | |||||
date_time_picker.classList.remove("active"); | |||||
date_time_picker.style.width = "220px"; | |||||
setTimeout(() => { | |||||
date_time_picker.classList.add("active"); | |||||
}, 600); | |||||
} | |||||
var $col = $( | var $col = $( | ||||
'<div class="col grid-static-col col-xs-' + colsize + " " + add_class + '"></div>' | '<div class="col grid-static-col col-xs-' + colsize + " " + add_class + '"></div>' | ||||
) | ) | ||||
@@ -842,15 +896,68 @@ export default class GridRow { | |||||
.attr("data-fieldtype", df.fieldtype) | .attr("data-fieldtype", df.fieldtype) | ||||
.data("df", df) | .data("df", df) | ||||
.appendTo(this.row) | .appendTo(this.row) | ||||
// initialize grid for horizontal scroll on mobile devices. | |||||
.on("touchstart", function (event) { | |||||
grid_container = $(event.currentTarget).closest(".form-grid-container")[0]; | |||||
grid = $(event.currentTarget).closest(".form-grid")[0]; | |||||
grid.style.position != "relative" && $(grid).css("position", "relative"); | |||||
!grid.style.left && $(grid).css("left", 0); | |||||
start_x = event.touches[0].clientX; | |||||
start_y = event.touches[0].clientY; | |||||
inital_position_x = -parseFloat(grid.style.left || 0) + start_x; | |||||
}) | |||||
// calculate X and Y movement based on touch events. | |||||
.on("touchmove", function (event) { | |||||
if (input_in_focus) return; | |||||
let moved_x; | |||||
let moved_y; | |||||
if (!horizontal && !vertical) { | |||||
moved_x = Math.abs(start_x - event.touches[0].clientX); | |||||
moved_y = Math.abs(start_y - event.touches[0].clientY); | |||||
} | |||||
if (!vertical && moved_x > 16) { | |||||
horizontal = true; | |||||
} else if (!horizontal && moved_y > 16) { | |||||
vertical = true; | |||||
} | |||||
if (horizontal) { | |||||
event.preventDefault(); | |||||
let grid_start = inital_position_x - event.touches[0].clientX; | |||||
let grid_end = grid.clientWidth - grid_container.clientWidth + 2; | |||||
if (grid_start < 0) { | |||||
grid_start = 0; | |||||
} else if (grid_start > grid_end) { | |||||
grid_start = grid_end; | |||||
} | |||||
grid.style.left = `-${grid_start}px`; | |||||
} | |||||
}) | |||||
.on("touchend", function () { | |||||
vertical = false; | |||||
horizontal = false; | |||||
}) | |||||
.on("click", function () { | .on("click", function () { | ||||
if (frappe.ui.form.editable_row === me) { | |||||
return; | |||||
if (frappe.ui.form.editable_row !== me) { | |||||
var out = me.toggle_editable_row(); | |||||
} | } | ||||
var out = me.toggle_editable_row(); | |||||
var col = this; | var col = this; | ||||
setTimeout(function () { | |||||
$(col).find('input[type="Text"]:first').focus(); | |||||
}, 500); | |||||
let first_input_field = $(col).find('input[type="Text"]:first'); | |||||
first_input_field.length && on_input_focus(first_input_field); | |||||
first_input_field.trigger("focus"); | |||||
first_input_field.one("blur", () => (input_in_focus = false)); | |||||
first_input_field.data("fieldtype") == "Date" && handle_date_picker(); | |||||
return out; | return out; | ||||
}); | }); | ||||
@@ -1149,6 +1256,10 @@ export default class GridRow { | |||||
return this; | return this; | ||||
} | } | ||||
show_form() { | show_form() { | ||||
if (frappe.utils.is_xs()) { | |||||
$(this.grid.form_grid).css("min-width", "0"); | |||||
$(this.grid.form_grid).css("position", "unset"); | |||||
} | |||||
if (!this.grid_form) { | if (!this.grid_form) { | ||||
this.grid_form = new GridRowForm({ | this.grid_form = new GridRowForm({ | ||||
row: this, | row: this, | ||||
@@ -1187,6 +1298,10 @@ export default class GridRow { | |||||
} | } | ||||
} | } | ||||
hide_form() { | hide_form() { | ||||
if (frappe.utils.is_xs()) { | |||||
$(this.grid.form_grid).css("min-width", "738px"); | |||||
$(this.grid.form_grid).css("position", "relative"); | |||||
} | |||||
frappe.dom.unfreeze(); | frappe.dom.unfreeze(); | ||||
this.row.toggle(true); | this.row.toggle(true); | ||||
if (!frappe.dom.is_element_in_modal(this.row)) { | if (!frappe.dom.is_element_in_modal(this.row)) { | ||||
@@ -141,8 +141,16 @@ frappe.ui.form.Layout = class Layout { | |||||
fieldname: "__details", | fieldname: "__details", | ||||
}; | }; | ||||
let first_tab = this.fields[1].fieldtype === "Tab Break" ? this.fields[1] : null; | let first_tab = this.fields[1].fieldtype === "Tab Break" ? this.fields[1] : null; | ||||
if (!first_tab) { | if (!first_tab) { | ||||
this.fields.splice(1, 0, default_tab); | |||||
this.fields.splice(0, 0, default_tab); | |||||
} else { | |||||
// reshuffle __newname field to accomodate under 1st Tab Break | |||||
let newname_field = this.fields.find((df) => df.fieldname === "__newname"); | |||||
if (newname_field && newname_field.get_status(this) === "Write") { | |||||
this.fields.splice(0, 1); | |||||
this.fields.splice(1, 0, newname_field); | |||||
} | |||||
} | } | ||||
} | } | ||||
@@ -167,13 +167,12 @@ frappe.ui.form.ScriptManager = class ScriptManager { | |||||
setup() { | setup() { | ||||
const doctype = this.frm.meta; | const doctype = this.frm.meta; | ||||
const me = this; | const me = this; | ||||
let client_script; | |||||
let client_script = doctype.__js; | |||||
// process the custom script for this form | |||||
if (this.frm.doctype_layout) { | |||||
client_script = this.frm.doctype_layout.client_script; | |||||
} else { | |||||
client_script = doctype.__js; | |||||
// append the custom script for this form's layout | |||||
if (this.frm.doctype_layout?.client_script) { | |||||
// add a newline to avoid conflict with doctype JS | |||||
client_script += `\n${this.frm.doctype_layout.client_script}`; | |||||
} | } | ||||
if (client_script) { | if (client_script) { | ||||
@@ -190,12 +190,8 @@ frappe.router = { | |||||
} else { | } else { | ||||
route = ["List", doctype_route.doctype, "List"]; | route = ["List", doctype_route.doctype, "List"]; | ||||
} | } | ||||
if (doctype_route.doctype_layout) { | |||||
// set the layout | |||||
this.doctype_layout = doctype_route.doctype_layout; | |||||
} | |||||
// reset the layout to avoid using incorrect views | |||||
this.doctype_layout = doctype_route.doctype_layout; | |||||
return route; | return route; | ||||
}, | }, | ||||
@@ -42,16 +42,13 @@ frappe.socketio = { | |||||
data.percent = (flt(data.progress[0]) / data.progress[1]) * 100; | data.percent = (flt(data.progress[0]) / data.progress[1]) * 100; | ||||
} | } | ||||
if (data.percent) { | if (data.percent) { | ||||
if (data.percent == 100) { | |||||
frappe.hide_progress(); | |||||
} else { | |||||
frappe.show_progress( | |||||
data.title || __("Progress"), | |||||
data.percent, | |||||
100, | |||||
data.description | |||||
); | |||||
} | |||||
frappe.show_progress( | |||||
data.title || __("Progress"), | |||||
data.percent, | |||||
100, | |||||
data.description, | |||||
true | |||||
); | |||||
} | } | ||||
}); | }); | ||||
@@ -268,8 +268,8 @@ | |||||
.editable-row .frappe-control { | .editable-row .frappe-control { | ||||
padding-top: 0px !important; | padding-top: 0px !important; | ||||
padding-bottom: 0px !important; | padding-bottom: 0px !important; | ||||
margin-left: -5px !important; | |||||
margin-right: -5px !important; | |||||
margin-left: -1px !important; | |||||
margin-right: -1px !important; | |||||
} | } | ||||
} | } | ||||
@@ -484,6 +484,31 @@ | |||||
margin-bottom: 4px; | margin-bottom: 4px; | ||||
} | } | ||||
@media (max-width: map-get($grid-breakpoints, "md")) { | |||||
.form-grid-container { | |||||
overflow-x: clip; | |||||
.form-grid { | |||||
min-width: 738px; | |||||
} | |||||
} | |||||
.form-column.col-sm-6 .form-grid { | |||||
.row-index { | |||||
display: block; | |||||
} | |||||
} | |||||
} | |||||
@media (min-width: map-get($grid-breakpoints, "md")) { | |||||
.form-grid-container { | |||||
overflow-x: unset!important; | |||||
.form-grid { | |||||
position: unset!important; | |||||
} | |||||
} | |||||
} | |||||
@media (max-width: map-get($grid-breakpoints, "sm")) { | @media (max-width: map-get($grid-breakpoints, "sm")) { | ||||
.form-in-grid .form-section .form-column { | .form-in-grid .form-section .form-column { | ||||
@@ -13,7 +13,22 @@ from frappe.utils import cint | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def add( | |||||
def add(doctype, name, user=None, read=1, write=0, submit=0, share=0, everyone=0, notify=0): | |||||
"""Expose function without flags to the client-side""" | |||||
return add_docshare( | |||||
doctype, | |||||
name, | |||||
user=user, | |||||
read=read, | |||||
write=write, | |||||
submit=submit, | |||||
share=share, | |||||
everyone=everyone, | |||||
notify=notify, | |||||
) | |||||
def add_docshare( | |||||
doctype, name, user=None, read=1, write=0, submit=0, share=0, everyone=0, flags=None, notify=0 | doctype, name, user=None, read=1, write=0, submit=0, share=0, everyone=0, flags=None, notify=0 | ||||
): | ): | ||||
"""Share the given document with a user.""" | """Share the given document with a user.""" | ||||
@@ -66,21 +81,29 @@ def remove(doctype, name, user, flags=None): | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def set_permission(doctype, name, user, permission_to, value=1, everyone=0): | def set_permission(doctype, name, user, permission_to, value=1, everyone=0): | ||||
"""Expose function without flags to the client-side""" | |||||
set_docshare_permission(doctype, name, user, permission_to, value=value, everyone=everyone) | |||||
def set_docshare_permission(doctype, name, user, permission_to, value=1, everyone=0, flags=None): | |||||
"""Set share permission.""" | """Set share permission.""" | ||||
check_share_permission(doctype, name) | |||||
if not (flags or {}).get("ignore_share_permission"): | |||||
check_share_permission(doctype, name) | |||||
share_name = get_share_name(doctype, name, user, everyone) | share_name = get_share_name(doctype, name, user, everyone) | ||||
value = int(value) | value = int(value) | ||||
if not share_name: | if not share_name: | ||||
if value: | if value: | ||||
share = add(doctype, name, user, everyone=everyone, **{permission_to: 1}) | |||||
share = add_docshare(doctype, name, user, everyone=everyone, **{permission_to: 1}, flags=flags) | |||||
else: | else: | ||||
# no share found, nothing to remove | # no share found, nothing to remove | ||||
share = {} | share = {} | ||||
pass | pass | ||||
else: | else: | ||||
share = frappe.get_doc("DocShare", share_name) | share = frappe.get_doc("DocShare", share_name) | ||||
if flags: | |||||
share.flags.update(flags) | |||||
share.flags.ignore_permissions = True | share.flags.ignore_permissions = True | ||||
share.set(permission_to, value) | share.set(permission_to, value) | ||||
@@ -734,7 +734,7 @@ class TestDBSetValue(FrappeTestCase): | |||||
frappe.db.get_value("ToDo", todo.name, ["modified", "modified_by"]), | frappe.db.get_value("ToDo", todo.name, ["modified", "modified_by"]), | ||||
) | ) | ||||
def test_for_update(self): | |||||
def test_set_value(self): | |||||
self.todo1.reload() | self.todo1.reload() | ||||
with patch.object(Database, "sql") as sql_called: | with patch.object(Database, "sql") as sql_called: | ||||
@@ -745,28 +745,23 @@ class TestDBSetValue(FrappeTestCase): | |||||
f"{self.todo1.description}-edit by `test_for_update`", | f"{self.todo1.description}-edit by `test_for_update`", | ||||
) | ) | ||||
first_query = sql_called.call_args_list[0].args[0] | first_query = sql_called.call_args_list[0].args[0] | ||||
second_query = sql_called.call_args_list[1].args[0] | |||||
self.assertTrue(sql_called.call_count == 2) | |||||
self.assertTrue("FOR UPDATE" in first_query) | |||||
if frappe.conf.db_type == "postgres": | if frappe.conf.db_type == "postgres": | ||||
from frappe.database.postgres.database import modify_query | from frappe.database.postgres.database import modify_query | ||||
self.assertTrue(modify_query("UPDATE `tabToDo` SET") in second_query) | |||||
self.assertTrue(modify_query("UPDATE `tabToDo` SET") in first_query) | |||||
if frappe.conf.db_type == "mariadb": | if frappe.conf.db_type == "mariadb": | ||||
self.assertTrue("UPDATE `tabToDo` SET" in second_query) | |||||
self.assertTrue("UPDATE `tabToDo` SET" in first_query) | |||||
def test_cleared_cache(self): | def test_cleared_cache(self): | ||||
self.todo2.reload() | self.todo2.reload() | ||||
frappe.get_cached_doc(self.todo2.doctype, self.todo2.name) # init cache | |||||
with patch.object(frappe, "clear_document_cache") as clear_cache: | |||||
frappe.db.set_value( | |||||
self.todo2.doctype, | |||||
self.todo2.name, | |||||
"description", | |||||
f"{self.todo2.description}-edit by `test_cleared_cache`", | |||||
) | |||||
clear_cache.assert_called() | |||||
description = f"{self.todo2.description}-edit by `test_cleared_cache`" | |||||
frappe.db.set_value(self.todo2.doctype, self.todo2.name, "description", description) | |||||
cached_doc = frappe.get_cached_doc(self.todo2.doctype, self.todo2.name) | |||||
self.assertEqual(cached_doc.description, description) | |||||
def test_update_alias(self): | def test_update_alias(self): | ||||
args = (self.todo1.doctype, self.todo1.name, "description", "Updated by `test_update_alias`") | args = (self.todo1.doctype, self.todo1.name, "description", "Updated by `test_update_alias`") | ||||
@@ -50,6 +50,20 @@ class TestPerformance(FrappeTestCase): | |||||
with self.assertQueryCount(0): | with self.assertQueryCount(0): | ||||
frappe.get_meta("User") | frappe.get_meta("User") | ||||
def test_set_value_query_count(self): | |||||
frappe.db.set_value("User", "Administrator", "interest", "Nothing") | |||||
with self.assertQueryCount(1): | |||||
frappe.db.set_value("User", "Administrator", "interest", "Nothing") | |||||
with self.assertQueryCount(1): | |||||
frappe.db.set_value("User", {"user_type": "System User"}, "interest", "Nothing") | |||||
with self.assertQueryCount(1): | |||||
frappe.db.set_value( | |||||
"User", {"user_type": "System User"}, {"interest": "Nothing", "bio": "boring person"} | |||||
) | |||||
def test_controller_caching(self): | def test_controller_caching(self): | ||||
get_controller("User") | get_controller("User") | ||||
@@ -3,8 +3,11 @@ | |||||
import frappe | import frappe | ||||
from frappe.app import make_form_dict | |||||
from frappe.desk.search import get_names_for_mentions, search_link, search_widget | from frappe.desk.search import get_names_for_mentions, search_link, search_widget | ||||
from frappe.tests.utils import FrappeTestCase | from frappe.tests.utils import FrappeTestCase | ||||
from frappe.utils import set_request | |||||
from frappe.website.serve import get_response | |||||
class TestSearch(FrappeTestCase): | class TestSearch(FrappeTestCase): | ||||
@@ -235,3 +238,22 @@ def teardown_test_link_field_order(TestCase): | |||||
) | ) | ||||
TestCase.tree_doc.delete() | TestCase.tree_doc.delete() | ||||
class TestWebsiteSearch(FrappeTestCase): | |||||
def get(self, path, user="Guest"): | |||||
frappe.set_user(user) | |||||
set_request(method="GET", path=path) | |||||
make_form_dict(frappe.local.request) | |||||
response = get_response() | |||||
frappe.set_user("Administrator") | |||||
return response | |||||
def test_basic_search(self): | |||||
no_search = self.get("/search") | |||||
self.assertEqual(no_search.status_code, 200) | |||||
response = self.get("/search?q=b") | |||||
self.assertEqual(response.status_code, 200) | |||||
self.assertIn("Search Results", response.get_data(as_text=True)) |
@@ -317,6 +317,22 @@ class TestWebsite(FrappeTestCase): | |||||
self.assertIn('<meta name="title" content="Test Title Metatag">', content) | self.assertIn('<meta name="title" content="Test Title Metatag">', content) | ||||
self.assertIn('<meta name="description" content="Test Description for Metatag">', content) | self.assertIn('<meta name="description" content="Test Description for Metatag">', content) | ||||
def test_resolve_class(self): | |||||
from frappe.utils.jinja_globals import resolve_class | |||||
context = frappe._dict(primary=True) | |||||
self.assertEqual(resolve_class("test"), "test") | |||||
self.assertEqual(resolve_class("test", "test-2"), "test test-2") | |||||
self.assertEqual(resolve_class("test", {"test-2": False, "test-3": True}), "test test-3") | |||||
self.assertEqual( | |||||
resolve_class(["test1", "test2", context.primary and "primary"]), "test1 test2 primary" | |||||
) | |||||
content = '<a class="{{ resolve_class("btn btn-default", primary and "btn-primary") }}">Test</a>' | |||||
self.assertEqual( | |||||
frappe.render_template(content, context), '<a class="btn btn-default btn-primary">Test</a>' | |||||
) | |||||
def set_home_page_hook(key, value): | def set_home_page_hook(key, value): | ||||
from frappe import hooks | from frappe import hooks | ||||
@@ -2,12 +2,14 @@ | |||||
# License: MIT. See LICENSE | # License: MIT. See LICENSE | ||||
def resolve_class(classes): | |||||
def resolve_class(*classes): | |||||
if classes and len(classes) == 1: | |||||
classes = classes[0] | |||||
if classes is None: | if classes is None: | ||||
return "" | return "" | ||||
if isinstance(classes, str): | |||||
return classes | |||||
if classes is False: | |||||
return "" | |||||
if isinstance(classes, (list, tuple)): | if isinstance(classes, (list, tuple)): | ||||
return " ".join(resolve_class(c) for c in classes).strip() | return " ".join(resolve_class(c) for c in classes).strip() | ||||
@@ -64,7 +64,9 @@ class BaseTemplatePage(BaseRenderer): | |||||
self.context.url_prefix += "/" | self.context.url_prefix += "/" | ||||
self.context.path = self.path | self.context.path = self.path | ||||
self.context.pathname = frappe.local.path if hasattr(frappe, "local") else self.path | |||||
self.context.pathname = ( | |||||
getattr(frappe.local, "path", None) if hasattr(frappe, "local") else self.path | |||||
) | |||||
def update_website_context(self): | def update_website_context(self): | ||||
# apply context from hooks | # apply context from hooks | ||||
@@ -1,4 +1,4 @@ | |||||
from jinja2 import utils | |||||
import markupsafe | |||||
import frappe | import frappe | ||||
from frappe import _ | from frappe import _ | ||||
@@ -10,7 +10,7 @@ from frappe.utils.global_search import web_search | |||||
def get_context(context): | def get_context(context): | ||||
context.no_cache = 1 | context.no_cache = 1 | ||||
if frappe.form_dict.q: | if frappe.form_dict.q: | ||||
query = str(utils.escape(sanitize_html(frappe.form_dict.q))) | |||||
query = str(markupsafe.escape(sanitize_html(frappe.form_dict.q))) | |||||
context.title = _("Search Results for") | context.title = _("Search Results for") | ||||
context.query = query | context.query = query | ||||
context.route = "/search" | context.route = "/search" | ||||
@@ -20,7 +20,7 @@ dependencies = [ | |||||
"PyPika~=0.48.9", | "PyPika~=0.48.9", | ||||
"PyQRCode~=1.2.1", | "PyQRCode~=1.2.1", | ||||
"PyYAML~=5.4.1", | "PyYAML~=5.4.1", | ||||
"RestrictedPython~=5.1", | |||||
"RestrictedPython~=5.2", | |||||
"WeasyPrint==52.5", | "WeasyPrint==52.5", | ||||
"Werkzeug~=2.2.2", | "Werkzeug~=2.2.2", | ||||
"Whoosh~=2.7.4", | "Whoosh~=2.7.4", | ||||