diff --git a/cypress/integration/control_markdown_editor.js b/cypress/integration/control_markdown_editor.js new file mode 100644 index 0000000000..b527d854d4 --- /dev/null +++ b/cypress/integration/control_markdown_editor.js @@ -0,0 +1,22 @@ +context("Control Markdown Editor", () => { + before(() => { + cy.login(); + cy.visit("/app"); + }); + + it("should allow inserting images by drag and drop", () => { + cy.visit("/app/web-page/new"); + cy.fill_field("content_type", "Markdown", "Select"); + cy.get_field("main_section_md", "Markdown Editor").attachFile( + "sample_image.jpg", + { + subjectType: "drag-n-drop" + } + ); + cy.click_modal_primary_button("Upload"); + cy.get_field("main_section_md", "Markdown Editor").should( + "contain", + "![](/files/sample_image.jpg)" + ); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index d7aae12794..3b93bfaaf8 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -174,6 +174,9 @@ Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => { if (fieldtype === 'Code') { selector = `[data-fieldname="${fieldname}"] .ace_text-input`; } + if (fieldtype === 'Markdown Editor') { + selector = `[data-fieldname="${fieldname}"] .ace-editor-target`; + } return cy.get(selector).first(); }); diff --git a/frappe/__init__.py b/frappe/__init__.py index 37c282f04a..10c8afbf23 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -55,29 +55,23 @@ controllers = {} class _dict(dict): """dict like object that exposes keys as attributes""" - def __getattr__(self, key): - ret = self.get(key) - # "__deepcopy__" exception added to fix frappe#14833 via DFP - if not ret and key.startswith("__") and key != "__deepcopy__": - raise AttributeError() - return ret - - def __setattr__(self, key, value): - self[key] = value + __slots__ = () + __getattr__ = dict.get + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + __setstate__ = dict.update def __getstate__(self): return self - def __setstate__(self, d): - self.update(d) - - def update(self, d): + def update(self, *args, **kwargs): """update and return self -- the missing dict feature in python""" - super(_dict, self).update(d) + + super().update(*args, **kwargs) return self def copy(self): - return _dict(dict(self).copy()) + return _dict(self) def _(msg, lang=None, context=None): diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 3d9cf1064e..7675df6c38 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -99,7 +99,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -547,7 +547,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-02-14 11:56:19.812863", + "modified": "2022-03-02 17:07:32.117897", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 135172e8da..7b4806da59 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -2,6 +2,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import unittest +from typing import Dict, List, Optional import frappe from frappe.core.doctype.doctype.doctype import ( @@ -524,7 +525,7 @@ class TestDocType(unittest.TestCase): def test_autoincremented_doctype_transition(self): frappe.delete_doc("testy_autoinc_dt") - dt = new_doctype("testy_autoinc_dt", autoincremented=True).insert(ignore_permissions=True) + dt = new_doctype("testy_autoinc_dt", autoname="autoincrement").insert(ignore_permissions=True) dt.autoname = "hash" try: @@ -537,8 +538,39 @@ class TestDocType(unittest.TestCase): # cleanup dt.delete(ignore_permissions=True) + def test_json_field(self): + """Test json field.""" + import json -def new_doctype(name, unique=0, depends_on="", fields=None, autoincremented=False): + json_doc = new_doctype( + "Test Json Doctype", + fields=[{"label": "json field", "fieldname": "test_json_field", "fieldtype": "JSON"}], + ) + json_doc.insert() + json_doc.save() + doc = frappe.get_doc("DocType", "Test Json Doctype") + for field in doc.fields: + if field.fieldname == "test_json_field": + self.assertEqual(field.fieldtype, "JSON") + break + + doc = frappe.get_doc( + {"doctype": "Test Json Doctype", "test_json_field": json.dumps({"hello": "world"})} + ) + doc.insert() + doc.save() + + test_json = frappe.get_doc("Test Json Doctype", doc.name) + + if isinstance(test_json.test_json_field, str): + test_json.test_json_field = json.loads(test_json.test_json_field) + + self.assertEqual(test_json.test_json_field["hello"], "world") + + +def new_doctype( + name, unique: bool = False, depends_on: str = "", fields: Optional[List[Dict]] = None, **kwargs +): doc = frappe.get_doc( { "doctype": "DocType", @@ -560,7 +592,7 @@ def new_doctype(name, unique=0, depends_on="", fields=None, autoincremented=Fals } ], "name": name, - "autoname": "autoincrement" if autoincremented else "", + **kwargs, } ) diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 78d211bfe7..045a0981f3 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -123,7 +123,7 @@ "label": "Field Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break", + "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1 }, { @@ -439,7 +439,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2022-03-22 03:47:27.097911", + "modified": "2022-04-14 09:46:58.849765", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index d75363e454..0f5410a403 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -55,6 +55,7 @@ class MariaDBDatabase(Database): "Icon": ("varchar", self.VARCHAR_LEN), "Phone": ("varchar", self.VARCHAR_LEN), "Autocomplete": ("varchar", self.VARCHAR_LEN), + "JSON": ("json", ""), } def get_connection(self): diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 6f658f0193..4cd6ab9873 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -67,6 +67,7 @@ class PostgresDatabase(Database): "Icon": ("varchar", self.VARCHAR_LEN), "Phone": ("varchar", self.VARCHAR_LEN), "Autocomplete": ("varchar", self.VARCHAR_LEN), + "JSON": ("json", ""), } def get_connection(self): diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py index ed936bb79e..e864f68728 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.py +++ b/frappe/desk/doctype/kanban_board/kanban_board.py @@ -13,8 +13,9 @@ class KanbanBoard(Document): def validate(self): self.validate_column_name() - def on_update(self): + def on_change(self): frappe.clear_cache(doctype=self.reference_doctype) + frappe.cache().delete_keys("_user_settings") def before_insert(self): for column in self.columns: @@ -245,9 +246,3 @@ def set_indicator(board_name, column_name, indicator): board.save() return board - - -@frappe.whitelist() -def save_filters(board_name, filters): - """Save filters silently""" - frappe.db.set_value("Kanban Board", board_name, "filters", filters, update_modified=False) diff --git a/frappe/desk/doctype/system_console/system_console.py b/frappe/desk/doctype/system_console/system_console.py index ffebfe2dd0..f1324403c3 100644 --- a/frappe/desk/doctype/system_console/system_console.py +++ b/frappe/desk/doctype/system_console/system_console.py @@ -13,7 +13,7 @@ class SystemConsole(Document): def run(self): frappe.only_for("System Manager") try: - frappe.debug_log = [] + frappe.local.debug_log = [] if self.type == "Python": safe_exec(self.console) self.output = "\n".join(frappe.debug_log) diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 636b662a09..75cd403aac 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -57,7 +57,7 @@ def getdoc(doctype, name, user=None): doc.add_seen() set_link_titles(doc) if frappe.response.docs is None: - frappe.response = _dict({"docs": []}) + frappe.local.response = _dict({"docs": []}) frappe.response.docs.append(doc) diff --git a/frappe/installer.py b/frappe/installer.py index 3ce2bdf293..634d6287f8 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -242,8 +242,8 @@ def install_app(name, verbose=False, set_as_patched=True): # install pre-requisites if app_hooks.required_apps: for app in app_hooks.required_apps: - name = parse_app_name(app) - install_app(name, verbose=verbose) + required_app = parse_app_name(app) + install_app(required_app, verbose=verbose) frappe.flags.in_install = name frappe.clear_cache() @@ -579,7 +579,7 @@ def add_module_defs(app): d = frappe.new_doc("Module Def") d.app_name = app d.module_name = module - d.save(ignore_permissions=True) + d.insert(ignore_permissions=True, ignore_if_duplicate=True) def remove_missing_apps(): diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 121cfc9253..70300196f6 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -38,6 +38,7 @@ data_fieldtypes = ( "Icon", "Phone", "Autocomplete", + "JSON", ) attachment_fieldtypes = ( diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 784c398030..fb4521d58d 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -1,6 +1,7 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import datetime +import json import frappe from frappe import _ @@ -287,6 +288,9 @@ class BaseDocument(object): elif df.fieldtype == "Int" and not isinstance(d[fieldname], int): d[fieldname] = cint(d[fieldname]) + elif df.fieldtype == "JSON" and isinstance(d[fieldname], dict): + d[fieldname] = json.dumps(d[fieldname], sort_keys=True, indent=4, separators=(",", ": ")) + elif df.fieldtype in ("Currency", "Float", "Percent") and not isinstance(d[fieldname], float): d[fieldname] = flt(d[fieldname]) diff --git a/frappe/model/document.py b/frappe/model/document.py index ef2aa9a6dc..07ea58d8e9 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -3,6 +3,7 @@ import hashlib import json import time +from typing import List from werkzeug.exceptions import NotFound @@ -20,9 +21,6 @@ from frappe.utils import cstr, date_diff, file_lock, flt, get_datetime_str, now from frappe.utils.data import get_absolute_url from frappe.utils.global_search import update_global_search -# once_only validation -# methods - def get_doc(*args, **kwargs): """returns a frappe.model.Document object. @@ -188,7 +186,7 @@ class Document(BaseDocument): if not self.has_permission(permtype): self.raise_no_permission_to(permlevel or permtype) - def has_permission(self, permtype="read", verbose=False): + def has_permission(self, permtype="read", verbose=False) -> bool: """Call `frappe.has_permission` if `self.flags.ignore_permissions` is not set. @@ -212,7 +210,7 @@ class Document(BaseDocument): ignore_mandatory=None, set_name=None, set_child_names=True, - ): + ) -> "Document": """Insert the document in the database (as a new document). This will check for user permissions and execute `before_insert`, `validate`, `on_update`, `after_insert` methods if they are written. @@ -294,7 +292,7 @@ class Document(BaseDocument): """Wrapper for _save""" return self._save(*args, **kwargs) - def _save(self, ignore_permissions=None, ignore_version=None): + def _save(self, ignore_permissions=None, ignore_version=None) -> "Document": """Save the current document in the database in the **DocType**'s table or `tabSingles` (for single types). @@ -524,8 +522,7 @@ class Document(BaseDocument): self._save_passwords() self.validate_workflow() - children = self.get_all_children() - for d in children: + for d in self.get_all_children(): d._validate_data_fields() d._validate_selects() d._validate_non_negative() @@ -890,7 +887,7 @@ class Document(BaseDocument): msg = ", ".join((each[2] for each in cancelled_links)) frappe.throw(_("Cannot link cancelled document: {0}").format(msg), frappe.CancelledLinkError) - def get_all_children(self, parenttype=None): + def get_all_children(self, parenttype=None) -> List["Document"]: """Returns all children documents from **Table** type fields in a list.""" children = [] diff --git a/frappe/model/meta.py b/frappe/model/meta.py index c443777125..232d0a8d58 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -134,24 +134,22 @@ class Meta(Document): def as_dict(self, no_nulls=False): def serialize(doc): out = {} - for key in doc.__dict__: - value = doc.__dict__.get(key) - + for key, value in doc.__dict__.items(): if isinstance(value, (list, tuple)): - if len(value) > 0 and hasattr(value[0], "__dict__"): - value = [serialize(d) for d in value] - else: + if not value or not isinstance(value[0], BaseDocument): # non standard list object, skip continue - if isinstance(value, (str, int, float, datetime, list, tuple)) or ( - not no_nulls and value is None + value = [serialize(d) for d in value] + + if (not no_nulls and value is None) or isinstance( + value, (str, int, float, datetime, list, tuple) ): out[key] = value # set empty lists for unset table fields for table_field in DOCTYPE_TABLE_FIELDS: - if not out.get(table_field.fieldname): + if out.get(table_field.fieldname) is None: out[table_field.fieldname] = [] return out diff --git a/frappe/model/naming.py b/frappe/model/naming.py index eb755851fb..9d1079d995 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -46,9 +46,6 @@ def set_new_name(doc): elif getattr(doc.meta, "issingle", False): doc.name = doc.doctype - elif getattr(doc.meta, "istable", False): - doc.name = make_autoname("hash", doc.doctype) - if not doc.name: set_naming_from_document_naming_rule(doc) diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index 9cfbe163a5..00483bf6a5 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -107,7 +107,6 @@ def import_file_by_path( Returns: [bool]: True if import takes place. False if it wasn't imported. """ - frappe.flags.dt = frappe.flags.dt or [] try: docs = read_doc_from_file(path) except IOError: @@ -121,20 +120,19 @@ def import_file_by_path( docs = [docs] for doc in docs: - # modified timestamp in db, none if doctype's first import db_modified_timestamp = frappe.db.get_value(doc["doctype"], doc["name"], "modified") is_db_timestamp_latest = db_modified_timestamp and ( get_datetime(doc.get("modified")) <= get_datetime(db_modified_timestamp) ) - if not force or db_modified_timestamp: - try: - stored_hash = None - if doc["doctype"] == "DocType": + if not force and db_modified_timestamp: + stored_hash = None + if doc["doctype"] == "DocType": + try: stored_hash = frappe.db.get_value(doc["doctype"], doc["name"], "migration_hash") - except Exception: - frappe.flags.dt += [doc["doctype"]] + except Exception: + pass # if hash exists and is equal no need to update if stored_hash and stored_hash == calculated_hash: @@ -172,12 +170,6 @@ def import_file_by_path( return True -def is_timestamp_changed(doc): - # check if timestamps match - db_modified = frappe.db.get_value(doc["doctype"], doc["name"], "modified") - return not (db_modified and get_datetime(doc.get("modified")) == get_datetime(db_modified)) - - def read_doc_from_file(path): doc = None if os.path.exists(path): diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index ff1afcdd3c..6c816c1115 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -36,7 +36,7 @@ ref="file_input" @change="on_file_input" :multiple="allow_multiple" - :accept="restrictions.allowed_file_types.join(', ')" + :accept="(restrictions.allowed_file_types || []).join(', ')" > - +
+
+ +
+
+
+ + +
@@ -15,22 +42,31 @@ import Cropper from "cropperjs"; export default { name: "ImageCropper", - props: ["file", "attach_doc_image"], + props: ["file", "fixed_aspect_ratio"], data() { + let aspect_ratio = + this.fixed_aspect_ratio != null ? this.fixed_aspect_ratio : NaN; return { src: null, cropper: null, - image: null + image: null, + aspect_ratio }; }, + watch: { + aspect_ratio(value) { + if (this.cropper) { + this.cropper.setAspectRatio(value); + } + } + }, mounted() { if (window.FileReader) { let fr = new FileReader(); fr.onload = () => (this.src = fr.result); fr.readAsDataURL(this.file.cropper_file); } - aspect_ratio = this.attach_doc_image ? 1 : NaN; - crop_box = this.file.crop_box_data; + let crop_box = this.file.crop_box_data; this.image = this.$refs.image; this.image.onload = () => { this.cropper = new Cropper(this.image, { @@ -38,13 +74,31 @@ export default { scalable: false, viewMode: 1, data: crop_box, - aspectRatio: aspect_ratio + aspectRatio: this.aspect_ratio }); + window.cropper = this.cropper; }; }, computed: { - crop_button_text() { - return this.attach_doc_image ? "Upload" : "Crop"; + aspect_ratio_buttons() { + return [ + { + label: __("1:1"), + value: 1 + }, + { + label: __("4:3"), + value: 4 / 3 + }, + { + label: __("16:9"), + value: 16 / 9 + }, + { + label: __("Free"), + value: NaN + } + ]; } }, methods: { @@ -58,9 +112,6 @@ export default { }); this.file.file_obj = cropped_file_obj; this.$emit("toggle_image_cropper"); - if(this.attach_doc_image) { - this.$emit("upload_after_crop"); - } }, file_type); } } @@ -75,6 +126,8 @@ img { .image-cropper-actions { display: flex; - justify-content: flex-end; + align-items: center; + justify-content: space-between; + margin-top: var(--margin-md); } diff --git a/frappe/public/js/frappe/file_uploader/index.js b/frappe/public/js/frappe/file_uploader/index.js index ec90b19a1a..ab074c938d 100644 --- a/frappe/public/js/frappe/file_uploader/index.js +++ b/frappe/public/js/frappe/file_uploader/index.js @@ -10,11 +10,12 @@ export default class FileUploader { fieldname, files, folder, - restrictions, + restrictions = {}, upload_notes, allow_multiple, as_dataurl, disable_file_browser, + dialog_title, attach_doc_image, frm } = {}) { @@ -22,15 +23,11 @@ export default class FileUploader { frm && frm.attachments.max_reached(true); if (!wrapper) { - this.make_dialog(); + this.make_dialog(dialog_title); } else { this.wrapper = wrapper.get ? wrapper.get(0) : wrapper; } - if (attach_doc_image) { - restrictions.allowed_file_types = ['image/jpeg', 'image/png']; - } - this.$fileuploader = new Vue({ el: this.wrapper, render: h => h(FileUploaderComponent, { @@ -54,6 +51,10 @@ export default class FileUploader { this.uploader = this.$fileuploader.$children[0]; + if (!this.dialog) { + this.uploader.wrapper_ready = true; + } + this.uploader.$watch('files', (files) => { let all_private = files.every(file => file.private); if (this.dialog) { @@ -94,14 +95,17 @@ export default class FileUploader { return this.uploader.upload_files(); } - make_dialog() { + make_dialog(title) { this.dialog = new frappe.ui.Dialog({ - title: __('Upload'), + title: title || __('Upload'), primary_action_label: __('Upload'), primary_action: () => this.upload_files(), secondary_action_label: __('Set all private'), secondary_action: () => { this.uploader.toggle_all_private(); + }, + on_page_show: () => { + this.uploader.wrapper_ready = true; } }); diff --git a/frappe/public/js/frappe/form/controls/attach.js b/frappe/public/js/frappe/form/controls/attach.js index a91058a208..71ce2e8854 100644 --- a/frappe/public/js/frappe/form/controls/attach.js +++ b/frappe/public/js/frappe/form/controls/attach.js @@ -61,7 +61,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro } on_attach_doc_image() { this.set_upload_options(); - this.upload_options["attach_doc_image"] = true; + this.upload_options.restrictions.allowed_file_types = ['image/*']; + this.upload_options.restrictions.crop_image_aspect_ratio = 1; this.file_uploader = new frappe.ui.FileUploader(this.upload_options); } set_upload_options() { @@ -70,7 +71,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro on_success: file => { this.on_upload_complete(file); this.toggle_reload_button(); - } + }, + restrictions: {} }; if (this.frm) { diff --git a/frappe/public/js/frappe/form/controls/attach_image.js b/frappe/public/js/frappe/form/controls/attach_image.js index 7c24ec9551..0bae2e6241 100644 --- a/frappe/public/js/frappe/form/controls/attach_image.js +++ b/frappe/public/js/frappe/form/controls/attach_image.js @@ -19,7 +19,6 @@ frappe.ui.form.ControlAttachImage = class ControlAttachImage extends frappe.ui.f } set_upload_options() { super.set_upload_options(); - this.upload_options.restrictions = {}; this.upload_options.restrictions.allowed_file_types = ['image/*']; } }; diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js index 60805b75de..1ed37d7d17 100644 --- a/frappe/public/js/frappe/form/controls/code.js +++ b/frappe/public/js/frappe/form/controls/code.js @@ -54,17 +54,57 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex return this._autocompletions || []; }, set: (value) => { + let getter = value; + if (typeof getter !== 'function') { + getter = () => value; + } + if (!this._autocompletions) { + this._autocompletions = []; + } + this._autocompletions.push(getter); this.setup_autocompletion(); - this.df._autocompletions = value; } }); } - setup_autocompletion() { + setup_autocompletion(customGetCompletions) { if (this._autocompletion_setup) return; const ace = window.ace; - const get_autocompletions = () => this.df.autocompletions; + + let getCompletions = (editor, session, pos, prefix, callback) => { + if (prefix.length === 0) { + callback(null, []); + return; + } + const get_autocompletions = () => { + let getters = this._autocompletions || []; + let completions = []; + for (let getter of getters) { + let values = getter({ editor, session, pos, prefix }); + completions.push(...values); + } + return completions; + }; + let autocompletions = get_autocompletions(); + if (autocompletions.length) { + callback( + null, + autocompletions.map(a => { + if (typeof a === "string") { + a = { value: a }; + } + return { + name: "frappe", + value: a.value, + score: a.score, + meta: a.meta, + caption: a.caption + }; + }) + ); + } + }; ace.config.loadModule("ace/ext/language_tools", langTools => { this.editor.setOptions({ @@ -73,28 +113,7 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex }); langTools.addCompleter({ - getCompletions: function(editor, session, pos, prefix, callback) { - if (prefix.length === 0) { - callback(null, []); - return; - } - let autocompletions = get_autocompletions(); - if (autocompletions.length) { - callback( - null, - autocompletions.map(a => { - if (typeof a === 'string') { - a = { value: a }; - } - return { - name: 'frappe', - value: a.value, - score: a.score - }; - }) - ); - } - } + getCompletions: customGetCompletions || getCompletions }); }); this._autocompletion_setup = true; diff --git a/frappe/public/js/frappe/form/controls/control.js b/frappe/public/js/frappe/form/controls/control.js index 90536bd9fb..fd0f5dd860 100644 --- a/frappe/public/js/frappe/form/controls/control.js +++ b/frappe/public/js/frappe/form/controls/control.js @@ -40,6 +40,7 @@ import './rating'; import './duration'; import './icon'; import './phone'; +import './json'; frappe.ui.form.make_control = function (opts) { var control_class_name = "Control" + opts.df.fieldtype.replace(/ /g, ""); diff --git a/frappe/public/js/frappe/form/controls/json.js b/frappe/public/js/frappe/form/controls/json.js new file mode 100644 index 0000000000..ce2e0bd087 --- /dev/null +++ b/frappe/public/js/frappe/form/controls/json.js @@ -0,0 +1,6 @@ +frappe.ui.form.ControlJSON = class ControlCode extends frappe.ui.form.ControlCode { + set_language() { + this.editor.session.setMode('ace/mode/json'); + this.editor.setKeyboardHandler('ace/keyboard/vscode'); + } +}; diff --git a/frappe/public/js/frappe/form/controls/markdown_editor.js b/frappe/public/js/frappe/form/controls/markdown_editor.js index 5acf4bd467..774976abc6 100644 --- a/frappe/public/js/frappe/form/controls/markdown_editor.js +++ b/frappe/public/js/frappe/form/controls/markdown_editor.js @@ -29,6 +29,8 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp this.markdown_preview = $(`
`).hide(); this.markdown_container.append(this.markdown_preview); + + this.setup_image_drop(); } set_language() { @@ -53,4 +55,45 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp set_disp_area(value) { this.disp_area && $(this.disp_area).text(value); } + + setup_image_drop() { + this.ace_editor_target.on('drop', e => { + e.stopPropagation(); + e.preventDefault(); + let { dataTransfer } = e.originalEvent; + if (!dataTransfer?.files?.length) { + return; + } + let files = dataTransfer.files; + if (!files[0].type.includes('image')) { + frappe.show_alert({ + message: __('You can only insert images in Markdown fields', [files[0].name]), + indicator: 'orange' + }); + return; + } + + new frappe.ui.FileUploader({ + dialog_title: __('Insert Image in Markdown'), + doctype: this.doctype, + docname: this.docname, + frm: this.frm, + files, + folder: 'Home/Attachments', + allow_multiple: false, + restrictions: { + allowed_file_types: ['image/*'] + }, + on_success: (file_doc) => { + if (this.frm && !this.frm.is_new()) { + this.frm.attachments.attachment_uploaded(file_doc); + } + this.editor.session.insert( + this.editor.getCursorPosition(), + `![](${encodeURI(file_doc.file_url)})` + ); + } + }); + }); + } }; diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 6191e35073..0c8939cf5d 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -319,6 +319,25 @@ frappe.ui.form.Form = class FrappeForm { }); } + setup_image_autocompletions_in_markdown() { + this.fields.map(field => { + if (field.df.fieldtype === 'Markdown Editor') { + this.set_df_property(field.df.fieldname, 'autocompletions', () => { + let attachments = this.attachments.get_attachments(); + return attachments + .filter(file => frappe.utils.is_image_file(file.file_url)) + .map(file => { + return { + caption: 'image: ' + file.file_name, + value: `![](${file.file_url})`, + meta: 'image' + }; + }); + }); + } + }); + } + // REFRESH refresh(docname) { @@ -533,6 +552,7 @@ frappe.ui.form.Form = class FrappeForm { // call onload post render for callbacks to be fired () => { if(this.cscript.is_onload) { + this.onload_post_render(); return this.script_manager.trigger("onload_post_render"); } }, @@ -560,6 +580,10 @@ frappe.ui.form.Form = class FrappeForm { }); } + onload_post_render() { + this.setup_image_autocompletions_in_markdown(); + } + set_first_tab_as_active() { this.layout.tabs[0] && this.layout.tabs[0].set_active(); diff --git a/frappe/public/js/frappe/form/sidebar/attachments.js b/frappe/public/js/frappe/form/sidebar/attachments.js index 0713d5dc43..3663cfd9a5 100644 --- a/frappe/public/js/frappe/form/sidebar/attachments.js +++ b/frappe/public/js/frappe/form/sidebar/attachments.js @@ -62,7 +62,7 @@ frappe.ui.form.Attachments = class Attachments { } get_attachments() { - return this.frm.get_docinfo().attachments; + return this.frm.get_docinfo().attachments || []; } add_attachment(attachment) { var file_name = attachment.file_name; diff --git a/frappe/public/js/frappe/list/list_view_select.js b/frappe/public/js/frappe/list/list_view_select.js index 54e88ea05b..475019d6c1 100644 --- a/frappe/public/js/frappe/list/list_view_select.js +++ b/frappe/public/js/frappe/list/list_view_select.js @@ -262,19 +262,27 @@ frappe.views.ListViewSelect = class ListViewSelect { setup_kanban_boards() { const last_opened_kanban = - frappe.model.user_settings[this.doctype]["Kanban"] && frappe.model.user_settings[this.doctype]["Kanban"] - .last_kanban_board; - if (last_opened_kanban) { - frappe.set_route( - "list", + ?.last_kanban_board; + + if (!last_opened_kanban) { + return frappe.views.KanbanView.show_kanban_dialog( this.doctype, - "kanban", - last_opened_kanban + true ); - } else { - frappe.views.KanbanView.show_kanban_dialog(this.doctype, true); } + frappe.db.exists("Kanban Board", last_opened_kanban).then(exists => { + if (exists) { + frappe.set_route( + "list", + this.doctype, + "kanban", + last_opened_kanban + ); + } else { + frappe.views.KanbanView.show_kanban_dialog(this.doctype, true); + } + }); } get_calendars() { diff --git a/frappe/public/js/frappe/views/kanban/kanban_view.js b/frappe/public/js/frappe/views/kanban/kanban_view.js index 89d1d41836..3bf3a16189 100644 --- a/frappe/public/js/frappe/views/kanban/kanban_view.js +++ b/frappe/public/js/frappe/views/kanban/kanban_view.js @@ -107,13 +107,7 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView { save_kanban_board_filters() { const filters = this.filter_area.get(); - frappe.call({ - method: 'frappe.desk.doctype.kanban_board.kanban_board.save_filters', - args: { - board_name: this.board_name, - filters: filters - } - }).then(r => { + frappe.db.set_value("Kanban Board", this.board_name, "filters", filters).then(r => { if (r.exc) { frappe.show_alert({ indicator: 'red', @@ -253,25 +247,36 @@ frappe.views.KanbanView.show_kanban_dialog = function (doctype, show_existing) { } function new_kanban_dialog(kanbans, show_existing) { + /* Kanban dialog can show either "Save" or "Customize Form" option depending if any Select fields exist in the DocType for Kanban creation + */ if (dialog) return dialog; - const fields = get_fields_for_dialog(kanbans.map(kanban => kanban.name), show_existing); - - let primary_action_label = __('Save'); + const dialog_fields = get_fields_for_dialog(kanbans.map(kanban => kanban.name), show_existing); + const select_fields = frappe.get_meta(doctype).fields.filter(df => { + return (df.fieldtype === 'Select') && (df.fieldname !== 'kanban_column') + }); + const to_save = select_fields.length > 0; + const primary_action_label = to_save ? __('Save') : __('Customize Form'); let primary_action = () => { - const values = dialog.get_values(); - if (!values.selected_kanban || values.selected_kanban == 'Create New Board') { - make_kanban_board(values.board_name, values.field_name, values.project) - .then(() => dialog.hide(), (err) => frappe.msgprint(err)); + if (to_save) { + const values = dialog.get_values(); + if (!values.selected_kanban || values.selected_kanban == 'Create New Board') { + make_kanban_board(values.board_name, values.field_name, values.project).then( + () => dialog.hide(), + (err) => frappe.msgprint(err) + ); + } else { + frappe.set_route(kanbans.find(kanban => kanban.name == values.selected_kanban).route); + } } else { - frappe.set_route(kanbans.find(kanban => kanban.name == values.selected_kanban).route); + frappe.set_route("Form", "Customize Form", {"doc_type": doctype}); } }; dialog = new frappe.ui.Dialog({ title: __('New Kanban Board'), - fields, + fields: dialog_fields, primary_action_label, primary_action }); @@ -280,6 +285,9 @@ frappe.views.KanbanView.show_kanban_dialog = function (doctype, show_existing) { function get_fields_for_dialog(kanban_options, show_existing = false) { kanban_options.push('Create New Board'); + const select_fields = frappe.get_meta(doctype).fields.filter(df => { + return df.fieldtype === 'Select' && df.fieldname !== 'kanban_column'; + }); let fields = [ { @@ -290,6 +298,7 @@ frappe.views.KanbanView.show_kanban_dialog = function (doctype, show_existing) { depends_on: `eval: ${show_existing}`, mandatory_depends_on: `eval: ${show_existing}`, options: kanban_options, + default: kanban_options[0] }, { fieldname: 'new_kanban_board_sb', @@ -315,13 +324,6 @@ frappe.views.KanbanView.show_kanban_dialog = function (doctype, show_existing) { }); } - const select_fields = - frappe.get_meta(doctype).fields - .filter(df => { - return df.fieldtype === 'Select' && - df.fieldname !== 'kanban_column'; - }); - if (select_fields.length > 0) { fields.push({ fieldtype: 'Select', @@ -339,9 +341,6 @@ frappe.views.KanbanView.show_kanban_dialog = function (doctype, show_existing) {

${__('No fields found that can be used as a Kanban Column. Use the Customize Form to add a Custom Field of type "Select".')}

- - ${__('Customize Form')} -
` }]; diff --git a/frappe/public/scss/common/modal.scss b/frappe/public/scss/common/modal.scss index 0de34f4ae4..8e69a956e5 100644 --- a/frappe/public/scss/common/modal.scss +++ b/frappe/public/scss/common/modal.scss @@ -211,21 +211,18 @@ body.modal-open[style^="padding-right"] { display: flex; align-items: center; - .frappe-control { + .frappe-control:first-child { &[data-fieldname="sender"] { - flex: 1; - margin-bottom: 0px; + margin-right: 10px; } - &[data-fieldname="recipients"] { - margin-left: 10px; - } - &[data-fieldname="option_toggle_button"] { - margin-left: 10px; - margin-bottom: -24px; - button { - // same as form-control input - height: calc(1.5em + .75rem + 2px); - } + flex: 1; + } + .frappe-control:last-child { + margin-left: 10px; + margin-bottom: -24px; + button { + // same as form-control input + height: calc(1.5em + .75rem + 2px); } } } diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 624f346716..f722ad1d65 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -759,7 +759,7 @@ class TestDDLCommandsPost(unittest.TestCase): def test_sequence_table_creation(self): from frappe.core.doctype.doctype.test_doctype import new_doctype - dt = new_doctype("autoinc_dt_seq_test", autoincremented=True).insert(ignore_permissions=True) + dt = new_doctype("autoinc_dt_seq_test", autoname="autoincrement").insert(ignore_permissions=True) if frappe.db.db_type == "postgres": self.assertTrue( diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 90b047b3cd..8bdd66a045 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -619,7 +619,7 @@ class TestReportview(unittest.TestCase): def test_cast_name(self): from frappe.core.doctype.doctype.test_doctype import new_doctype - dt = new_doctype("autoinc_dt_test", autoincremented=True).insert(ignore_permissions=True) + dt = new_doctype("autoinc_dt_test", autoname="autoincrement").insert(ignore_permissions=True) query = DatabaseQuery("autoinc_dt_test").execute( fields=["locate('1', `tabautoinc_dt_test`.`name`)", "`tabautoinc_dt_test`.`name`"], diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py index e57d2ae4cd..33974e5d27 100644 --- a/frappe/tests/test_naming.py +++ b/frappe/tests/test_naming.py @@ -262,7 +262,7 @@ class TestNaming(unittest.TestCase): from frappe.core.doctype.doctype.test_doctype import new_doctype doctype = "autoinc_doctype" + frappe.generate_hash(length=5) - dt = new_doctype(doctype, autoincremented=True).insert(ignore_permissions=True) + dt = new_doctype(doctype, autoname="autoincrement").insert(ignore_permissions=True) for i in range(1, 20): self.assertEqual(frappe.new_doc(doctype).save(ignore_permissions=True).name, i) diff --git a/frappe/tests/test_nestedset.py b/frappe/tests/test_nestedset.py new file mode 100644 index 0000000000..2d601ec185 --- /dev/null +++ b/frappe/tests/test_nestedset.py @@ -0,0 +1,235 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + +import frappe +from frappe.core.doctype.doctype.test_doctype import new_doctype +from frappe.query_builder import Field +from frappe.query_builder.functions import Max +from frappe.tests.utils import FrappeTestCase +from frappe.utils.nestedset import ( + NestedSetChildExistsError, + NestedSetInvalidMergeError, + NestedSetRecursionError, + get_descendants_of, + rebuild_tree, +) + +records = [ + { + "some_fieldname": "Root Node", + "parent_test_tree_doctype": None, + "is_group": 1, + }, + { + "some_fieldname": "Parent 1", + "parent_test_tree_doctype": "Root Node", + "is_group": 1, + }, + { + "some_fieldname": "Parent 2", + "parent_test_tree_doctype": "Root Node", + "is_group": 1, + }, + { + "some_fieldname": "Child 1", + "parent_test_tree_doctype": "Parent 1", + "is_group": 0, + }, + { + "some_fieldname": "Child 2", + "parent_test_tree_doctype": "Parent 1", + "is_group": 0, + }, + { + "some_fieldname": "Child 3", + "parent_test_tree_doctype": "Parent 2", + "is_group": 0, + }, +] + + +class NestedSetTestUtil: + def setup_test_doctype(self): + frappe.db.sql("delete from `tabDocType` where `name` = 'Test Tree DocType'") + frappe.db.sql_ddl("drop table if exists `tabTest Tree DocType`") + + self.tree_doctype = new_doctype( + "Test Tree DocType", is_tree=True, autoname="field:some_fieldname" + ) + self.tree_doctype.insert() + + for record in records: + d = frappe.new_doc("Test Tree DocType") + d.update(record) + d.insert() + + def teardown_test_doctype(self): + self.tree_doctype.delete() + frappe.db.sql_ddl("drop table if exists `tabTest Tree DocType`") + + def move_it_back(self): + parent_1 = frappe.get_doc("Test Tree DocType", "Parent 1") + parent_1.parent_test_tree_doctype = "Root Node" + parent_1.save() + + def get_no_of_children(self, record_name: str) -> int: + if not record_name: + return frappe.db.count("Test Tree DocType") + return len(get_descendants_of("Test Tree DocType", record_name, ignore_permissions=True)) + + +class TestNestedSet(FrappeTestCase): + @classmethod + def setUpClass(cls) -> None: + cls.nsu = NestedSetTestUtil() + cls.nsu.setup_test_doctype() + super().setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + cls.nsu.teardown_test_doctype() + super().tearDownClass() + + def setUp(self) -> None: + frappe.db.rollback() + + def test_basic_tree(self): + global records + + min_lft = 1 + max_rgt = frappe.qb.from_("Test Tree DocType").select(Max(Field("rgt"))).run(pluck=True)[0] + + for record in records: + lft, rgt, parent_test_tree_doctype = frappe.db.get_value( + "Test Tree DocType", + record["some_fieldname"], + ["lft", "rgt", "parent_test_tree_doctype"], + ) + + if parent_test_tree_doctype: + parent_lft, parent_rgt = frappe.db.get_value( + "Test Tree DocType", parent_test_tree_doctype, ["lft", "rgt"] + ) + else: + # root + parent_lft = min_lft - 1 + parent_rgt = max_rgt + 1 + + self.assertTrue(lft) + self.assertTrue(rgt) + self.assertTrue(lft < rgt) + self.assertTrue(parent_lft < parent_rgt) + self.assertTrue(lft > parent_lft) + self.assertTrue(rgt < parent_rgt) + self.assertTrue(lft >= min_lft) + self.assertTrue(rgt <= max_rgt) + + no_of_children = self.nsu.get_no_of_children(record["some_fieldname"]) + self.assertTrue( + rgt == (lft + 1 + (2 * no_of_children)), + msg=(record, no_of_children, self.nsu.get_no_of_children(record["some_fieldname"])), + ) + + no_of_children = self.nsu.get_no_of_children(parent_test_tree_doctype) + self.assertTrue(parent_rgt == (parent_lft + 1 + (2 * no_of_children))) + + def test_recursion(self): + leaf_node = frappe.get_doc("Test Tree DocType", {"some_fieldname": "Parent 2"}) + leaf_node.parent_test_tree_doctype = "Child 3" + self.assertRaises(NestedSetRecursionError, leaf_node.save) + leaf_node.reload() + + def test_rebuild_tree(self): + rebuild_tree("Test Tree DocType", "parent_test_tree_doctype") + self.test_basic_tree() + + def test_move_group_into_another(self): + old_lft, old_rgt = frappe.db.get_value("Test Tree DocType", "Parent 2", ["lft", "rgt"]) + + parent_1 = frappe.get_doc("Test Tree DocType", "Parent 1") + lft, rgt = parent_1.lft, parent_1.rgt + + parent_1.parent_test_tree_doctype = "Parent 2" + parent_1.save() + self.test_basic_tree() + + # after move + new_lft, new_rgt = frappe.db.get_value("Test Tree DocType", "Parent 2", ["lft", "rgt"]) + + # lft should reduce + self.assertEqual(old_lft - new_lft, rgt - lft + 1) + + # adjacent siblings, hence rgt diff will be 0 + self.assertEqual(new_rgt - old_rgt, 0) + + self.nsu.move_it_back() + self.test_basic_tree() + + def test_move_leaf_into_another_group(self): + child_2 = frappe.get_doc("Test Tree DocType", "Child 2") + + # assert that child 2 is not already under parent 1 + parent_lft_old, parent_rgt_old = frappe.db.get_value( + "Test Tree DocType", "Parent 2", ["lft", "rgt"] + ) + self.assertTrue((parent_lft_old > child_2.lft) and (parent_rgt_old > child_2.rgt)) + + child_2.parent_test_tree_doctype = "Parent 2" + child_2.save() + self.test_basic_tree() + + # assert that child 2 is under parent 1 + parent_lft_new, parent_rgt_new = frappe.db.get_value( + "Test Tree DocType", "Parent 2", ["lft", "rgt"] + ) + self.assertFalse((parent_lft_new > child_2.lft) and (parent_rgt_new > child_2.rgt)) + + def test_delete_leaf(self): + global records + el = {"some_fieldname": "Child 1", "parent_test_tree_doctype": "Parent 1", "is_group": 0} + + child_1 = frappe.get_doc("Test Tree DocType", "Child 1") + child_1.delete() + records.remove(el) + + self.test_basic_tree() + + n = frappe.new_doc("Test Tree DocType") + n.update(el) + n.insert() + records.append(el) + + self.test_basic_tree() + + def test_delete_group(self): + # cannot delete group with child, but can delete leaf + with self.assertRaises(NestedSetChildExistsError): + frappe.delete_doc("Test Tree DocType", "Parent 1") + + def test_merge_groups(self): + global records + el = {"some_fieldname": "Parent 2", "parent_test_tree_doctype": "Root Node", "is_group": 1} + frappe.rename_doc("Test Tree DocType", "Parent 2", "Parent 1", merge=True) + records.remove(el) + self.test_basic_tree() + + def test_merge_leaves(self): + global records + el = {"some_fieldname": "Child 3", "parent_test_tree_doctype": "Parent 2", "is_group": 0} + + frappe.rename_doc( + "Test Tree DocType", + "Child 3", + "Child 2", + merge=True, + ) + records.remove(el) + self.test_basic_tree() + + def test_merge_leaf_into_group(self): + with self.assertRaises(NestedSetInvalidMergeError): + frappe.rename_doc("Test Tree DocType", "Child 1", "Parent 1", merge=True) + + def test_merge_group_into_leaf(self): + with self.assertRaises(NestedSetInvalidMergeError): + frappe.rename_doc("Test Tree DocType", "Parent 1", "Child 1", merge=True)