Merge branch 'develop' of https://github.com/frappe/frappe into phone_field_control

This commit is contained in:
Suraj Shetty 2022-04-14 14:19:35 +05:30
commit 6b7fda495b
36 changed files with 614 additions and 181 deletions

View 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",
"![](/files/sample_image.jpg)"
);
});
});

View file

@ -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();
});

View file

@ -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):

View file

@ -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",

View file

@ -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,
}
)

View file

@ -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",

View file

@ -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):

View file

@ -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):

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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():

View file

@ -38,6 +38,7 @@ data_fieldtypes = (
"Icon",
"Phone",
"Autocomplete",
"JSON",
)
attachment_fieldtypes = (

View file

@ -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])

View file

@ -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 = []

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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();

View file

@ -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>

View file

@ -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;
}
});

View file

@ -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) {

View file

@ -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/*'];
}
};

View file

@ -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;

View file

@ -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, "");

View 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');
}
};

View file

@ -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(),
`![](${encodeURI(file_doc.file_url)})`
);
}
});
});
}
};

View file

@ -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();

View file

@ -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;

View file

@ -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() {

View file

@ -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>
`
}];

View file

@ -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);
}
}
}

View file

@ -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(

View file

@ -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`"],

View file

@ -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)

View 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)