Merge branch 'develop' of https://github.com/frappe/frappe into phone_field_control
This commit is contained in:
commit
6b7fda495b
36 changed files with 614 additions and 181 deletions
22
cypress/integration/control_markdown_editor.js
Normal file
22
cypress/integration/control_markdown_editor.js
Normal file
|
|
@ -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",
|
||||
""
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ data_fieldtypes = (
|
|||
"Icon",
|
||||
"Phone",
|
||||
"Autocomplete",
|
||||
"JSON",
|
||||
)
|
||||
|
||||
attachment_fieldtypes = (
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(', ')"
|
||||
>
|
||||
<button class="btn btn-file-upload" v-if="!disable_file_browser" @click="show_file_browser = true">
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
@ -108,9 +108,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<ImageCropper
|
||||
v-if="show_image_cropper"
|
||||
v-if="show_image_cropper && wrapper_ready"
|
||||
:file="files[crop_image_with_index]"
|
||||
:attach_doc_image="attach_doc_image"
|
||||
:fixed_aspect_ratio="restrictions.crop_image_aspect_ratio"
|
||||
@toggle_image_cropper="toggle_image_cropper(-1)"
|
||||
@upload_after_crop="trigger_upload=true"
|
||||
/>
|
||||
|
|
@ -171,7 +171,8 @@ export default {
|
|||
default: () => ({
|
||||
max_file_size: null, // 2048 -> 2KB
|
||||
max_number_of_files: null,
|
||||
allowed_file_types: [] // ['image/*', 'video/*', '.jpg', '.gif', '.pdf']
|
||||
allowed_file_types: [], // ['image/*', 'video/*', '.jpg', '.gif', '.pdf'],
|
||||
crop_image_aspect_ratio: null // 1, 16 / 9, 4 / 3, NaN (free)
|
||||
})
|
||||
},
|
||||
attach_doc_image: {
|
||||
|
|
@ -203,7 +204,8 @@ export default {
|
|||
allow_web_link: true,
|
||||
google_drive_settings: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
wrapper_ready: false
|
||||
}
|
||||
},
|
||||
created() {
|
||||
|
|
@ -286,11 +288,12 @@ export default {
|
|||
.filter(this.check_restrictions)
|
||||
.map(file => {
|
||||
let is_image = file.type.startsWith('image');
|
||||
let size_kb = file.size / 1024;
|
||||
return {
|
||||
file_obj: file,
|
||||
cropper_file: file,
|
||||
crop_box_data: null,
|
||||
optimize: this.attach_doc_image ? true : false,
|
||||
optimize: size_kb > 200 && is_image && !file.type.includes('svg'),
|
||||
name: file.name,
|
||||
doc: null,
|
||||
progress: 0,
|
||||
|
|
@ -303,12 +306,15 @@ export default {
|
|||
}
|
||||
});
|
||||
this.files = this.files.concat(files);
|
||||
if(this.files.length != 0 && this.attach_doc_image) {
|
||||
this.toggle_image_cropper(0);
|
||||
// if only one file is allowed and crop_image_aspect_ratio is set, open cropper immediately
|
||||
if (this.files.length === 1 && !this.allow_multiple && this.restrictions.crop_image_aspect_ratio != null) {
|
||||
if (!this.files[0].file_obj.type.includes('svg')) {
|
||||
this.toggle_image_cropper(0);
|
||||
}
|
||||
}
|
||||
},
|
||||
check_restrictions(file) {
|
||||
let { max_file_size, allowed_file_types } = this.restrictions;
|
||||
let { max_file_size, allowed_file_types = [] } = this.restrictions;
|
||||
|
||||
let mime_type = file.type;
|
||||
let extension = '.' + file.name.split('.').pop();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,39 @@
|
|||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<img ref="image" :src="src" :alt="file.name"/>
|
||||
<img ref="image" :src="src" :alt="file.name" />
|
||||
</div>
|
||||
<br/>
|
||||
<div class="image-cropper-actions">
|
||||
<button class="btn btn-sm margin-right" v-if="!attach_doc_image" @click="$emit('toggle_image_cropper')">Back</button>
|
||||
<button class="btn btn-primary btn-sm margin-right" @click="crop_image" v-html="crop_button_text"></button>
|
||||
<div>
|
||||
<div class="btn-group" v-if="fixed_aspect_ratio == null">
|
||||
<button
|
||||
v-for="button in aspect_ratio_buttons"
|
||||
type="button"
|
||||
class="btn btn-default btn-sm"
|
||||
:class="{
|
||||
active: isNaN(aspect_ratio)
|
||||
? isNaN(button.value)
|
||||
: button.value === aspect_ratio
|
||||
}"
|
||||
:key="button.label"
|
||||
@click="aspect_ratio = button.value"
|
||||
>
|
||||
{{ button.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-sm margin-right"
|
||||
@click="$emit('toggle_image_cropper')"
|
||||
v-if="fixed_aspect_ratio == null"
|
||||
>
|
||||
{{ __("Back") }}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" @click="crop_image">
|
||||
{{ __("Crop") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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/*'];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
|
|
|
|||
6
frappe/public/js/frappe/form/controls/json.js
Normal file
6
frappe/public/js/frappe/form/controls/json.js
Normal file
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -29,6 +29,8 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp
|
|||
|
||||
this.markdown_preview = $(`<div class="${editor_class}-preview border rounded">`).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(),
|
||||
`})`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: ``,
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<p class="text-medium">
|
||||
${__('No fields found that can be used as a Kanban Column. Use the Customize Form to add a Custom Field of type "Select".')}
|
||||
</p>
|
||||
<a class="btn btn-xs btn-default" href="/app/customize-form?doc_type=${doctype}">
|
||||
${__('Customize Form')}
|
||||
</a>
|
||||
</div>
|
||||
`
|
||||
}];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`"],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
235
frappe/tests/test_nestedset.py
Normal file
235
frappe/tests/test_nestedset.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue