Merge branch 'develop' of https://github.com/frappe/frappe into bg-submissions

This commit is contained in:
Aradhya 2022-10-12 19:34:00 +05:30
commit fdf1ed9656
134 changed files with 3657 additions and 12768 deletions

View file

@ -1,7 +1,7 @@
import sys
import requests
from urllib.parse import urlparse
import requests
docs_repos = [
"frappe_docs",

View file

@ -118,7 +118,8 @@ jobs:
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
BUILD_NUMBER: ${{ matrix.container }}
TOTAL_BUILDS: 2
- name: Upload coverage data
uses: actions/upload-artifact@v3

View file

@ -121,7 +121,8 @@ jobs:
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
BUILD_NUMBER: ${{ matrix.container }}
TOTAL_BUILDS: 2
- name: Upload coverage data
uses: actions/upload-artifact@v3

View file

@ -120,6 +120,13 @@ jobs:
TYPE: ui
DB: mariadb
- name: Verify yarn.lock
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: |
cd ~/frappe-bench/apps/frappe
yarn install --immutable --immutable-cache --check-cache
git diff --exit-code yarn.lock
- name: Instrument Source Code
run: cd ~/frappe-bench/apps/frappe/ && npx nyc instrument -x 'frappe/public/dist/**' -x 'frappe/public/js/lib/**' -x '**/*.bundle.js' --compact=false --in-place frappe

View file

@ -22,6 +22,7 @@ coverage:
comment:
layout: "diff, flags"
require_changes: true
show_critical_paths: true
flags:
server-mariadb:
@ -40,3 +41,24 @@ flags:
paths:
- "**/*.py"
carryforward: true
profiling:
critical_files_paths:
- /frappe/api.py
- /frappe/app.py
- /frappe/auth.py
- /frappe/boot.py
- /frappe/client.py
- /frappe/handler.py
- /frappe/migrate.py
- /frappe/sessions.py
- /frappe/utils/*
- /frappe/desk/reportview.py
- /frappe/desk/form/*
- /frappe/model/*
- /frappe/core/doctype/doctype/*
- /frappe/core/doctype/data_import/*
- /frappe/core/doctype/user/*
- /frappe/core/doctype/user/*
- /frappe/query_builder/*
- /frappe/database/*

View file

@ -17,7 +17,7 @@ context("Dashboard links", () => {
.window()
.its("frappe")
.then((frappe) => {
return frappe.xcall("frappe.tests.ui_test_helpers.update_child_table", {
frappe.call("frappe.tests.ui_test_helpers.update_child_table", {
name: child_table_doctype_name,
});
});

View file

@ -71,7 +71,6 @@ context("Workspace Blocks", () => {
url: "api/method/frappe.desk.form.load.getdoctype?**",
}).as("get_doctype");
cy.visit("/app/tools");
cy.get(".codex-editor__redactor .ce-block");
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
@ -79,10 +78,8 @@ context("Workspace Blocks", () => {
cy.get(".ce-block").first().click({ force: true }).type("{enter}");
cy.get(".block-list-container .block-list-item").contains("Quick List").click();
cy.get_open_dialog().find(".modal-header").click();
cy.fill_field("label", "ToDo", "Data");
cy.fill_field("document_type", "ToDo", "Link").blur();
cy.fill_field("label", "ToDo", "Data").blur();
cy.wait("@get_doctype");
cy.get_open_dialog().find(".filter-edit-area").should("contain", "No filters selected");
@ -122,6 +119,7 @@ context("Workspace Blocks", () => {
cy.get_open_dialog()
.find(".filter-field .input-with-feedback")
.focus()
.type("{selectall}Approved");
cy.get_open_dialog().find(".modal-header").click();
cy.get_open_dialog().find(".btn-primary").click();

View file

@ -285,7 +285,7 @@ Cypress.Commands.add("get_open_dialog", () => {
Cypress.Commands.add("save", () => {
cy.intercept("/api/method/frappe.desk.form.save.savedocs").as("save_call");
cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: false, force: true });
cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: "top", force: true });
cy.wait("@save_call");
});
Cypress.Commands.add("hide_dialog", () => {

View file

@ -3,11 +3,12 @@ const path = require("path");
const fs = require("fs");
const glob = require("fast-glob");
const esbuild = require("esbuild");
const vue = require("esbuild-vue");
const vue = require("esbuild-plugin-vue3");
const yargs = require("yargs");
const cliui = require("cliui")();
const chalk = require("chalk");
const html_plugin = require("./frappe-html");
const vue_style_plugin = require("./frappe-vue-style");
const rtlcss = require("rtlcss");
const postCssPlugin = require("@frappe/esbuild-plugin-postcss2").default;
const ignore_assets = require("./ignore-assets");
@ -218,7 +219,7 @@ function get_files_to_build(files) {
}
function build_files({ files, outdir }) {
let build_plugins = [html_plugin, build_cleanup_plugin, vue()];
let build_plugins = [vue(), html_plugin, build_cleanup_plugin, vue_style_plugin];
return esbuild.build(get_build_options(files, outdir, build_plugins));
}
@ -254,6 +255,8 @@ function get_build_options(files, outdir, plugins) {
nodePaths: NODE_PATHS,
define: {
"process.env.NODE_ENV": JSON.stringify(PRODUCTION ? "production" : "development"),
__VUE_OPTIONS_API__: JSON.stringify(true),
__VUE_PROD_DEVTOOLS__: JSON.stringify(false),
},
plugins: plugins,
watch: get_watch_config(),

View file

@ -0,0 +1,59 @@
const fs = require("fs");
const path = require("path");
const { sites_path } = require("./utils");
module.exports = {
name: "frappe-vue-style",
setup(build) {
build.initialOptions.write = false;
build.onEnd((result) => {
let files = get_files(result.metafile.outputs);
let keys = Object.keys(files);
for (let out of result.outputFiles) {
let asset_path = "/" + path.relative(sites_path, out.path);
let dir = path.dirname(out.path);
if (out.path.endsWith(".js") && keys.includes(asset_path)) {
let name = out.path.split(".bundle.")[0];
name = path.basename(name);
let index = result.outputFiles.findIndex((f) => {
return f.path.endsWith(".css") && f.path.includes(`/${name}.bundle.`);
});
let css_data = JSON.stringify(result.outputFiles[index].text);
let modified = `frappe.dom.set_style(${css_data});\n${out.text}`;
out.contents = Buffer.from(modified);
result.outputFiles.splice(index, 1);
if (result.outputFiles[index - 1].path.endsWith(".css.map")) {
result.outputFiles.splice(index - 1, 1);
}
}
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFile(out.path, out.contents, (err) => {
err && console.error(err);
});
}
});
},
};
function get_files(files) {
let result = {};
for (let file in files) {
let info = files[file];
let asset_path = "/" + path.relative(sites_path, file);
if (info && info.entryPoint && Object.keys(info.inputs).length !== 0) {
for (let input in info.inputs) {
if (input.includes(".vue?type=style")) {
let bundle_css = path.basename(info.entryPoint).replace(".js", ".css");
result[asset_path] = bundle_css;
break;
}
}
}
}
return result;
}

View file

@ -3,6 +3,7 @@
import base64
import binascii
import json
from typing import Literal
from urllib.parse import urlencode, urlparse
import frappe
@ -49,106 +50,148 @@ def handle():
if len(parts) > 3:
name = parts[3]
if call == "method":
frappe.local.form_dict.cmd = doctype
return frappe.handler.handle()
return _RESTAPIHandler(call, doctype, name).get_response()
elif call == "resource":
if "run_method" in frappe.local.form_dict:
method = frappe.local.form_dict.pop("run_method")
doc = frappe.get_doc(doctype, name)
doc.is_whitelisted(method)
if frappe.local.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
class _RESTAPIHandler:
def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None):
self.call = call
self.doctype = doctype
self.name = name
if frappe.local.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
def get_response(self):
"""Prepare and get response based on URL and form body.
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
frappe.db.commit()
else:
if name:
if frappe.local.request.method == "GET":
doc = frappe.get_doc(doctype, name)
if not doc.has_permission("read"):
raise frappe.PermissionError
frappe.local.response.update({"data": doc})
if frappe.local.request.method == "PUT":
data = get_request_form_data()
doc = frappe.get_doc(doctype, name, for_update=True)
if "flags" in data:
del data["flags"]
# Not checking permissions here because it's checked in doc.save
doc.update(data)
frappe.local.response.update({"data": doc.save().as_dict()})
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
if frappe.local.request.method == "DELETE":
# Not checking permissions here because it's checked in delete_doc
frappe.delete_doc(doctype, name, ignore_missing=False)
frappe.local.response.http_status_code = 202
frappe.local.response.message = "ok"
frappe.db.commit()
elif doctype:
if frappe.local.request.method == "GET":
# set fields for frappe.get_list
if frappe.local.form_dict.get("fields"):
frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.local.form_dict.setdefault(
"limit_page_length",
frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
)
# convert strings to native types - only as_dict and debug accept bool
for param in ["as_dict", "debug"]:
param_val = frappe.local.form_dict.get(param)
if param_val is not None:
frappe.local.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
data = frappe.call(frappe.client.get_list, doctype, **frappe.local.form_dict)
# set frappe.get_list result to response
frappe.local.response.update({"data": data})
if frappe.local.request.method == "POST":
# fetch data from from dict
data = get_request_form_data()
data.update({"doctype": doctype})
# insert document from request data
doc = frappe.get_doc(data).insert()
# set response data
frappe.local.response.update({"data": doc.as_dict()})
# commit for POST requests
frappe.db.commit()
else:
Note: most methods of this class directly operate on the response local.
"""
match self.call:
case "method":
return self.handle_method()
case "resource":
self.handle_resource()
case _:
raise frappe.DoesNotExistError
else:
raise frappe.DoesNotExistError
return build_response("json")
return build_response("json")
def handle_method(self):
frappe.local.form_dict.cmd = self.doctype
return frappe.handler.handle()
def handle_resource(self):
if self.doctype and self.name:
self.handle_document_resource()
elif self.doctype:
self.handle_doctype_resource()
else:
raise frappe.DoesNotExistError
def handle_document_resource(self):
if "run_method" in frappe.local.form_dict:
self.execute_doc_method()
return
match frappe.local.request.method:
case "GET":
self.get_doc()
case "PUT":
self.update_doc()
case "DELETE":
self.delete_doc()
case _:
raise frappe.DoesNotExistError
def handle_doctype_resource(self):
match frappe.local.request.method:
case "GET":
self.get_doc_list()
case "POST":
self.create_doc()
case _:
raise frappe.DoesNotExistError
def execute_doc_method(self):
method = frappe.local.form_dict.pop("run_method")
doc = frappe.get_doc(self.doctype, self.name)
doc.is_whitelisted(method)
if frappe.local.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
elif frappe.local.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
frappe.db.commit()
def get_doc(self):
doc = frappe.get_doc(self.doctype, self.name)
if not doc.has_permission("read"):
raise frappe.PermissionError
frappe.local.response.update({"data": doc})
def update_doc(self):
data = get_request_form_data()
doc = frappe.get_doc(self.doctype, self.name, for_update=True)
if "flags" in data:
del data["flags"]
# Not checking permissions here because it's checked in doc.save
doc.update(data)
frappe.local.response.update({"data": doc.save().as_dict()})
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
def delete_doc(self):
# Not checking permissions here because it's checked in delete_doc
frappe.delete_doc(self.doctype, self.name, ignore_missing=False)
frappe.local.response.http_status_code = 202
frappe.local.response.message = "ok"
frappe.db.commit()
def get_doc_list(self):
if frappe.local.form_dict.get("fields"):
frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.local.form_dict.setdefault(
"limit_page_length",
frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
)
# convert strings to native types - only as_dict and debug accept bool
for param in ["as_dict", "debug"]:
param_val = frappe.local.form_dict.get(param)
if param_val is not None:
frappe.local.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
data = frappe.call(frappe.client.get_list, self.doctype, **frappe.local.form_dict)
# set frappe.get_list result to response
frappe.local.response.update({"data": data})
def create_doc(self):
data = get_request_form_data()
data.update({"doctype": self.doctype})
# insert document from request data
doc = frappe.get_doc(data).insert()
# set response data
frappe.local.response.update({"data": doc.as_dict()})
# commit for POST requests
frappe.db.commit()
def get_request_form_data():

View file

@ -101,6 +101,7 @@ def get_bootinfo():
bootinfo.app_logo_url = get_app_logo()
bootinfo.link_title_doctypes = get_link_title_doctypes()
bootinfo.translated_doctypes = get_translated_doctypes()
bootinfo.subscription_expiry = add_subscription_expiry()
return bootinfo
@ -428,3 +429,10 @@ def load_currency_docs(bootinfo):
)
bootinfo.docs += currency_docs
def add_subscription_expiry():
try:
return frappe.conf.subscription["expiry"]
except Exception:
return ""

View file

@ -819,9 +819,16 @@ def run_tests(
@click.option("--total-builds", help="Total number of builds", default=1)
@click.option("--with-coverage", is_flag=True, help="Build coverage file")
@click.option("--use-orchestrator", is_flag=True, help="Use orchestrator to run parallel tests")
@click.option("--dry-run", is_flag=True, default=False, help="Dont actually run tests")
@pass_context
def run_parallel_tests(
context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False
context,
app,
build_number,
total_builds,
with_coverage=False,
use_orchestrator=False,
dry_run=False,
):
from traceback_with_variables import activate_by_import
@ -834,7 +841,13 @@ def run_parallel_tests(
else:
from frappe.parallel_test_runner import ParallelTestRunner
ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)
ParallelTestRunner(
app,
site=site,
build_number=build_number,
total_builds=total_builds,
dry_run=dry_run,
)
@click.command(

View file

@ -19,6 +19,7 @@ from frappe.core.doctype.communication.mixins import CommunicationEmailMixin
from frappe.core.utils import get_parent_doc
from frappe.model.document import Document
from frappe.utils import (
cstr,
parse_addr,
split_emails,
strip_html,
@ -175,7 +176,7 @@ class Communication(Document, CommunicationEmailMixin):
if html_signature:
_signature = html_signature.renderContents()
if (_signature or signature) not in self.content:
if (cstr(_signature) or signature) not in self.content:
self.content = f'{self.content}</p><br><p class="signature">{signature}'
def before_save(self):

View file

@ -165,7 +165,8 @@ class CommunicationEmailMixin:
)
if self.sent_or_received == "Sent" and self._outgoing_email_account:
self.db_set("email_account", self._outgoing_email_account.name)
if frappe.db.exists("Email Account", self._outgoing_email_account.name):
self.db_set("email_account", self._outgoing_email_account.name)
return self._outgoing_email_account

View file

@ -27,6 +27,9 @@ frappe.ui.form.on("Data Export", {
reset_filter_and_field(frm);
}
},
export_without_main_header: (frm) => {
frm.refresh();
},
});
const can_export = (frm) => {
@ -58,8 +61,9 @@ const export_data = (frm) => {
select_columns: JSON.stringify(columns),
filters: frm.filter_list.get_filters().map((filter) => filter.slice(1, 4)),
file_type: frm.doc.file_type,
template: true,
template: !frm.doc.export_without_main_header,
with_data: 1,
export_without_column_meta: frm.doc.export_without_main_header ? true : false,
};
};

View file

@ -6,6 +6,7 @@
"engine": "InnoDB",
"field_order": [
"reference_doctype",
"export_without_main_header",
"column_break_2",
"file_type",
"section_break",
@ -47,12 +48,19 @@
"fieldname": "fields_multicheck",
"fieldtype": "HTML",
"label": "Fields Multicheck"
},
{
"default": "0",
"description": "Export the data without any header notes and column descriptions",
"fieldname": "export_without_main_header",
"fieldtype": "Check",
"label": "Export without main header"
}
],
"hide_toolbar": 1,
"issingle": 1,
"links": [],
"modified": "2022-08-03 12:20:53.658574",
"modified": "2022-09-28 03:51:02.404681",
"modified_by": "Administrator",
"module": "Core",
"name": "Data Export",

View file

@ -37,6 +37,7 @@ def export_data(
file_type="CSV",
template=False,
filters=None,
export_without_column_meta=False,
):
_doctype = doctype
if isinstance(_doctype, list):
@ -48,6 +49,15 @@ def export_data(
filters=filters,
method=parent_doctype,
)
template_bool = template
if isinstance(template, str):
template_bool = template.lower() == "true"
export_without_column_meta_bool = export_without_column_meta
if isinstance(export_without_column_meta, str):
export_without_column_meta_bool = export_without_column_meta.lower() == "true"
exporter = DataExporter(
doctype=doctype,
parent_doctype=parent_doctype,
@ -55,8 +65,9 @@ def export_data(
with_data=with_data,
select_columns=select_columns,
file_type=file_type,
template=template,
template=template_bool,
filters=filters,
export_without_column_meta=export_without_column_meta_bool,
)
exporter.build_response()
@ -72,6 +83,7 @@ class DataExporter:
file_type="CSV",
template=False,
filters=None,
export_without_column_meta=False,
):
self.doctype = doctype
self.parent_doctype = parent_doctype
@ -81,6 +93,7 @@ class DataExporter:
self.file_type = file_type
self.template = template
self.filters = filters
self.export_without_column_meta = export_without_column_meta
self.data_keys = get_data_keys()
self.prepare_args()
@ -117,7 +130,10 @@ class DataExporter:
if self.template:
self.add_main_header()
self.writer.writerow([""])
# No need of empty row at the start
if not self.export_without_column_meta:
self.writer.writerow([""])
self.tablerow = [self.data_keys.doctype]
self.labelrow = [_("Column Labels:")]
self.fieldrow = [self.data_keys.columns]
@ -310,12 +326,18 @@ class DataExporter:
return ""
def add_field_headings(self):
self.writer.writerow(self.tablerow)
if not self.export_without_column_meta:
self.writer.writerow(self.tablerow)
# Just include Labels in the first row
self.writer.writerow(self.labelrow)
self.writer.writerow(self.fieldrow)
self.writer.writerow(self.mandatoryrow)
self.writer.writerow(self.typerow)
self.writer.writerow(self.inforow)
if not self.export_without_column_meta:
self.writer.writerow(self.fieldrow)
self.writer.writerow(self.mandatoryrow)
self.writer.writerow(self.typerow)
self.writer.writerow(self.inforow)
if self.template:
self.writer.writerow([self.data_keys.data_separator])

View file

@ -572,12 +572,15 @@ class ImportFile:
######
def read_file(self, file_path):
def read_file(self, file_path: str):
extn = os.path.splitext(file_path)[1][1:]
file_content = None
with open(file_path, mode="rb") as f:
file_content = f.read()
file_name = frappe.db.get_value("File", {"file_url": file_path})
if file_name:
file = frappe.get_doc("File", file_name)
file_content = file.get_content()
return file_content, extn

View file

@ -321,7 +321,8 @@
"depends_on": "eval:!doc.istable",
"fieldname": "title_field",
"fieldtype": "Data",
"label": "Title Field"
"label": "Title Field",
"mandatory_depends_on": "eval:doc.show_title_field_in_link"
},
{
"depends_on": "eval:!doc.istable",
@ -695,7 +696,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2022-10-06 15:20:12.186038",
"modified": "2022-09-02 12:05:59.589751",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

@ -422,7 +422,6 @@ class File(Document):
return os.path.exists(self.get_full_path())
def get_content(self) -> bytes:
"""Returns [`file_name`, `content`] for given file name `fname`"""
if self.is_folder:
frappe.throw(_("Cannot get file contents of a Folder"))

View file

@ -22,7 +22,7 @@ class Recorder {
}
show() {
if (!this.view || this.view.$route.name == "recorder-detail") return;
this.view.$router.replace({ name: "recorder-detail" });
if (!this.route || this.route.name == "RecorderDetail") return;
this.router?.replace({ name: "RecorderDetail" });
}
}

View file

@ -102,6 +102,20 @@ class CustomField(Document):
# delete property setter entries
frappe.db.delete("Property Setter", {"doc_type": self.dt, "field_name": self.fieldname})
# update doctype layouts
doctype_layouts = frappe.get_all(
"DocType Layout", filters={"document_type": self.dt}, pluck="name"
)
for layout in doctype_layouts:
layout_doc = frappe.get_doc("DocType Layout", layout)
for field in layout_doc.fields:
if field.fieldname == self.fieldname:
layout_doc.remove(field)
layout_doc.save()
break
frappe.clear_cache(doctype=self.dt)
def validate_insert_after(self, meta):

View file

@ -2,31 +2,104 @@
// For license information, please see license.txt
frappe.ui.form.on("DocType Layout", {
refresh: function (frm) {
frm.trigger("document_type");
frm.events.set_button(frm);
onload_post_render(frm) {
// disallow users from manually adding/deleting rows; this doctype should only
// be used for managing layout, and docfields and custom fields should be used
// to manage other field metadata (hidden, etc.)
frm.set_df_property("fields", "cannot_add_rows", true);
frm.set_df_property("fields", "cannot_delete_rows", true);
$(frm.wrapper).on("grid-move-row", (e, frm) => {
// refresh the layout after moving a row
frm.dirty();
});
},
document_type(frm) {
frm.set_fields_as_options("fields", frm.doc.document_type, null, [], "fieldname").then(
() => {
// child table empty? then show all fields as default
if (frm.doc.document_type) {
if (!(frm.doc.fields || []).length) {
for (let f of frappe.get_doc("DocType", frm.doc.document_type).fields) {
frm.add_child("fields", { fieldname: f.fieldname, label: f.label });
}
}
}
refresh(frm) {
frm.events.add_buttons(frm);
},
async document_type(frm) {
if (frm.doc.document_type) {
// refreshing the doctype fields resets the new name input field;
// once the fields are set, reset the name to the original input
if (frm.is_new()) {
const document_name = frm.doc.__newname || frm.doc.name;
}
);
frm.set_value("fields", []);
await frm.events.sync_fields(frm, false);
if (frm.is_new()) {
frm.doc.__newname = document_name;
frm.refresh_field("__newname");
}
}
},
set_button(frm) {
add_buttons(frm) {
if (!frm.is_new()) {
frm.add_custom_button(__("Go to {0} List", [frm.doc.name]), () => {
window.open(`/app/${frappe.router.slug(frm.doc.name)}`);
});
frm.add_custom_button(__("Sync {0} Fields", [frm.doc.name]), async () => {
await frm.events.sync_fields(frm, true);
});
}
},
async sync_fields(frm, notify) {
frappe.dom.freeze("Fetching fields...");
const response = await frm.call({ doc: frm.doc, method: "sync_fields" });
frm.refresh_field("fields");
frappe.dom.unfreeze();
if (!response.message) {
frappe.msgprint(__("No changes to sync"));
return;
}
frm.dirty();
if (notify) {
const addedFields = response.message.added;
const removedFields = response.message.removed;
const getChangedMessage = (fields) => {
let changes = "";
for (const field of fields) {
if (field.label) {
changes += `<li>Row #${field.idx}: ${field.fieldname.bold()} (${
field.label
})</li>`;
} else {
changes += `<li>Row #${field.idx}: ${field.fieldname.bold()}</li>`;
}
}
return changes;
};
let message = "";
if (addedFields.length) {
message += `The following fields have been added:<br><br><ul>${getChangedMessage(
addedFields
)}</ul>`;
}
if (removedFields.length) {
message += `The following fields have been removed:<br><br><ul>${getChangedMessage(
removedFields
)}</ul>`;
}
if (message) {
frappe.msgprint({
message: __(message),
indicator: "green",
title: __("Synced Fields"),
});
}
}
},
});

View file

@ -1,7 +1,7 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "Prompt",
"autoname": "prompt",
"creation": "2020-11-16 17:05:35.306846",
"doctype": "DocType",
"editable_grid": 1,
@ -19,7 +19,8 @@
"in_list_view": 1,
"label": "Document Type",
"options": "DocType",
"reqd": 1
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "fields",
@ -42,10 +43,11 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-12-10 15:01:04.352184",
"modified": "2022-09-01 03:22:33.973058",
"modified_by": "Administrator",
"module": "Custom",
"name": "DocType Layout",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@ -68,5 +70,6 @@
"route": "doctype-layout",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -1,11 +1,77 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# License: MIT. See LICENSE
from typing import TYPE_CHECKING
import frappe
from frappe.desk.utils import slug
from frappe.model.document import Document
if TYPE_CHECKING:
from frappe.core.doctype.docfield.docfield import DocField
class DocTypeLayout(Document):
def validate(self):
if not self.route:
self.route = slug(self.name)
@frappe.whitelist()
def sync_fields(self):
doctype_fields = frappe.get_meta(self.document_type).fields
if self.is_new():
added_fields = [field.fieldname for field in doctype_fields]
removed_fields = []
else:
doctype_fieldnames = {field.fieldname for field in doctype_fields}
layout_fieldnames = {field.fieldname for field in self.fields}
added_fields = list(doctype_fieldnames - layout_fieldnames)
removed_fields = list(layout_fieldnames - doctype_fieldnames)
if not (added_fields or removed_fields):
return
added = self.add_fields(added_fields, doctype_fields)
removed = self.remove_fields(removed_fields)
for index, field in enumerate(self.fields):
field.idx = index + 1
return {"added": added, "removed": removed}
def add_fields(self, added_fields: list[str], doctype_fields: list["DocField"]) -> list[dict]:
added = []
for field in added_fields:
field_details = next((f for f in doctype_fields if f.fieldname == field), None)
if not field_details:
continue
# remove 'doctype' data from the DocField to allow adding it to the layout
row = self.append("fields", field_details.as_dict(no_default_fields=True))
row_data = row.as_dict()
if field_details.get("insert_after"):
insert_after = next(
(f for f in self.fields if f.fieldname == field_details.insert_after),
None,
)
# initialize new row to just after the insert_after field
if insert_after:
self.fields.insert(insert_after.idx, row)
self.fields.pop()
row_data = {"idx": insert_after.idx + 1, "fieldname": row.fieldname, "label": row.label}
added.append(row_data)
return added
def remove_fields(self, removed_fields: list[str]) -> list[dict]:
removed = []
for field in removed_fields:
field_details = next((f for f in self.fields if f.fieldname == field), None)
if field_details:
self.remove(field_details)
removed.append(field_details.as_dict())
return removed

View file

@ -28,7 +28,6 @@ from frappe.database.utils import (
from frappe.exceptions import DoesNotExistError, ImplicitCommitError
from frappe.model.utils.link_count import flush_local_link_count
from frappe.query_builder.functions import Count
from frappe.query_builder.utils import DocType
from frappe.utils import cast as cast_fieldtype
from frappe.utils import get_datetime, get_table_name, getdate, now, sbool
@ -858,7 +857,7 @@ class Database:
:param modified_by: Set this user as `modified_by`.
:param update_modified: default True. Set as false, if you don't want to update the timestamp.
:param debug: Print the query in the developer / js console.
:param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit.
:param for_update: [DEPRECATED] This function now performs updates in single query, locking is not required.
"""
is_single_doctype = not (dn and dt != dn)
to_update = field if isinstance(field, dict) else {field: val}
@ -880,19 +879,11 @@ class Database:
frappe.clear_document_cache(dt, dt)
else:
table = DocType(dt)
if for_update:
docnames = tuple(
self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True)
) or (NullValue(),)
query = frappe.qb.update(table).where(table.name.isin(docnames))
for docname in docnames:
frappe.clear_document_cache(dt, docname)
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True)
if isinstance(dn, str):
frappe.clear_document_cache(dt, dn)
else:
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True)
# TODO: Fix this; doesn't work rn - gavin@frappe.io
# frappe.cache().hdel_keys(dt, "document_cache")
# Workaround: clear all document caches

View file

@ -301,6 +301,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
where table_name="{table_name}"
and column_name=columns.column_name
and NON_UNIQUE=1
and Seq_in_index = 1
limit 1
), 0) as 'index',
column_key = 'UNI' as 'unique'
@ -319,6 +320,37 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
)
)
def get_column_index(
self, table_name: str, fieldname: str, unique: bool = False
) -> frappe._dict | None:
"""Check if column exists for a specific fields in specified order.
This differs from db.has_index because it doesn't rely on index name but columns inside an
index.
"""
indexes = self.sql(
f"""SHOW INDEX FROM `{table_name}`
WHERE Column_name = "{fieldname}"
AND Seq_in_index = 1
AND Non_unique={int(not unique)}
""",
as_dict=True,
)
# Same index can be part of clustered index which contains more fields
# We don't want those.
for index in indexes:
clustered_index = self.sql(
f"""SHOW INDEX FROM `{table_name}`
WHERE Key_name = "{index.Key_name}"
AND Seq_in_index = 2
""",
as_dict=True,
)
if not clustered_index:
return index
def add_index(self, doctype: str, fields: list, index_name: str = None):
"""Creates an index with given fields if not already created.
Index name will be `fieldname1_fieldname2_index`"""

View file

@ -83,45 +83,23 @@ class MariaDBTable(DBTable):
for col in self.add_index:
# if index key does not exists
if not frappe.db.has_index(self.table_name, col.fieldname + "_index"):
if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False):
add_index_query.append(f"ADD INDEX `{col.fieldname}_index`(`{col.fieldname}`)")
for col in self.drop_index + self.drop_unique:
if col.fieldname != "name": # primary key
current_column = self.current_columns.get(col.fieldname.lower())
unique_constraint_changed = current_column.unique != col.unique
if unique_constraint_changed and not col.unique:
# nosemgrep
unique_index_record = frappe.db.sql(
"""
SHOW INDEX FROM `{}`
WHERE Key_name=%s
AND Non_unique=0
""".format(
self.table_name
),
(col.fieldname),
as_dict=1,
)
if unique_index_record:
drop_index_query.append(f"DROP INDEX `{unique_index_record[0].Key_name}`")
index_constraint_changed = current_column.index != col.set_index
# if index key exists
if index_constraint_changed and not col.set_index:
# nosemgrep
index_record = frappe.db.sql(
"""
SHOW INDEX FROM `{}`
WHERE Key_name=%s
AND Non_unique=1
""".format(
self.table_name
),
(col.fieldname + "_index"),
as_dict=1,
)
if index_record:
drop_index_query.append(f"DROP INDEX `{index_record[0].Key_name}`")
if col.fieldname == "name":
continue
current_column = self.current_columns.get(col.fieldname.lower())
unique_constraint_changed = current_column.unique != col.unique
if unique_constraint_changed and not col.unique:
if unique_index := frappe.db.get_column_index(self.table_name, col.fieldname, unique=True):
drop_index_query.append(f"DROP INDEX `{unique_index.Key_name}`")
index_constraint_changed = current_column.index != col.set_index
if index_constraint_changed and not col.set_index:
if index_record := frappe.db.get_column_index(self.table_name, col.fieldname, unique=False):
drop_index_query.append(f"DROP INDEX `{index_record.Key_name}`")
try:
for query_parts in [add_column_query, modify_column_query, add_index_query, drop_index_query]:

View file

@ -5,6 +5,7 @@ from functools import cached_property
from types import BuiltinFunctionType
from typing import TYPE_CHECKING, Callable
import sqlparse
from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder
import frappe
@ -14,6 +15,7 @@ from frappe.model.db_query import get_timespan_date_range
from frappe.query_builder import Criterion, Field, Order, Table, functions
from frappe.query_builder.functions import Function, SqlFunctions
from frappe.query_builder.utils import PseudoColumn
from frappe.utils.data import MARIADB_SPECIFIC_COMMENT
if TYPE_CHECKING:
from frappe.query_builder import DocType
@ -492,7 +494,8 @@ class Engine:
def get_fieldnames_from_child_table(self, doctype, fields):
# Hacky and flaky implementation of implicit joins.
# convert child_table.fieldname to `tabChild DocType`.`fieldname`
for idx, field in enumerate(fields, start=0):
_fields = []
for field in fields:
if "." in field and "tab" not in field:
alias = None
if " as " in field:
@ -506,12 +509,63 @@ class Engine:
field = f"`tab{self.linked_doctype}`.`{linked_fieldname}`"
if alias:
field = f"{field} as {alias}"
fields[idx] = field
_fields.append(field)
return _fields
def sanitize_fields(self, fields: str | list | tuple):
is_mariadb = frappe.db.db_type == "mariadb"
def _sanitize_field(field: str):
if not isinstance(field, str):
return field
stripped_field = sqlparse.format(field, strip_comments=True, keyword_case="lower")
if is_mariadb:
return MARIADB_SPECIFIC_COMMENT.sub("", stripped_field)
return stripped_field
if isinstance(fields, (list, tuple)):
return [_sanitize_field(field) for field in fields]
elif isinstance(fields, str):
return _sanitize_field(fields)
return fields
def set_fields(self, table, fields, **kwargs) -> list:
def get_list_fields(self, fields: list) -> list:
updated_fields = []
if issubclass(type(fields), Criterion) or "*" in fields:
return fields
# fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields)
for field in fields:
if not isinstance(field, Criterion) and field:
if " as " in field:
field, reference = field.split(" as ")
if "`" in field:
updated_fields.append(PseudoColumn(f"{field} as {reference}"))
else:
updated_fields.append(Field(field.strip()).as_(reference))
elif "`" in str(field):
updated_fields.append(PseudoColumn(field.strip()))
else:
updated_fields.append(Field(field))
return updated_fields
def get_string_fields(self, fields: str) -> Field:
if fields == "*":
return fields
if "`" in fields:
fields = PseudoColumn(fields)
if " as " in str(fields):
fields, reference = str(fields).split(" as ")
if "`" in str(fields):
fields = PseudoColumn(f"{fields} as {reference}")
else:
fields = Field(fields).as_(reference)
return fields
def set_fields(self, fields, **kwargs) -> list:
fields = kwargs.get("pluck") if kwargs.get("pluck") else fields or "name"
fields = self.sanitize_fields(fields)
if isinstance(fields, list) and None in fields and Field not in fields:
return None
function_objects = []
@ -535,39 +589,9 @@ class Engine:
is_list, is_str = True, False
if is_str:
if fields == "*":
return fields
if "`" in fields:
fields = PseudoColumn(fields)
if " as " in str(fields):
fields, reference = str(fields).split(" as ")
if "`" in str(fields):
fields = PseudoColumn(f"{fields} as {reference}")
else:
fields = Field(fields).as_(reference)
fields = self.get_string_fields(fields)
if not is_str and fields:
if issubclass(type(fields), Criterion):
return fields
updated_fields = []
if "*" in fields:
return fields
# fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields)
for field in fields:
if not isinstance(field, Criterion) and field:
if " as " in field:
field, reference = field.split(" as ")
if "`" in field:
updated_fields.append(PseudoColumn(f"{field} as {reference}"))
else:
updated_fields.append(Field(field.strip()).as_(reference))
elif "`" in str(field):
updated_fields.append(PseudoColumn(field.strip()))
else:
updated_fields.append(Field(field))
fields = updated_fields
fields = self.get_list_fields(fields)
# Need to check instance again since fields modified.
if not isinstance(fields, (list, tuple, set)):
@ -599,15 +623,17 @@ class Engine:
has_join = True
if has_join:
for idx, field in enumerate(fields):
def _update_pypika_fields(field):
if not is_pypika_function_object(field):
field = field if isinstance(field, str) else field.get_sql()
if not TABLE_PATTERN.search(str(field)):
fields[idx] = getattr(frappe.qb.DocType(table), field)
return getattr(frappe.qb.DocType(table), field)
else:
field.args = [getattr(frappe.qb.DocType(table), arg.get_sql()) for arg in field.args]
field.args[0] = getattr(frappe.qb.DocType(table), field.args[0].get_sql())
fields[idx] = field
return field
fields = [_update_pypika_fields(field) for field in fields]
if len(self.tables) > 1:
primary_table = self.tables.pop(table)
@ -631,7 +657,7 @@ class Engine:
self.linked_doctype = None
self.fieldname = None
fields = self.set_fields(table, kwargs.get("field_objects") or fields, **kwargs)
fields = self.set_fields(kwargs.get("field_objects") or fields, **kwargs)
criterion = self.build_conditions(table, filters, **kwargs)
join = kwargs.get("join").replace(" ", "_") if kwargs.get("join") else "left_join"
criterion, fields = self.join_(criterion=criterion, fields=fields, table=table, join=join)

View file

@ -293,13 +293,6 @@ def get_group_by_chart_config(chart, filters):
)
if data:
if chart.number_of_groups and chart.number_of_groups < len(data):
other_count = 0
for i in range(chart.number_of_groups - 1, len(data)):
other_count += data[i]["count"]
data = data[0 : chart.number_of_groups - 1]
data.append({"name": "Other", "count": other_count})
chart_config = {
"labels": [item["name"] if item["name"] else "Not Specified" for item in data],
"datasets": [{"name": chart.name, "values": [item["count"] for item in data]}],

View file

@ -20,6 +20,14 @@ class NotificationLog(Document):
except frappe.OutgoingEmailError:
self.log_error(_("Failed to send notification email"))
@staticmethod
def clear_old_logs(days=180):
from frappe.query_builder import Interval
from frappe.query_builder.functions import Now
table = frappe.qb.DocType("Notification Log")
frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))
def get_permission_query_conditions(for_user):
if not for_user:

View file

@ -0,0 +1,7 @@
frappe.listview_settings["Notification Log"] = {
onload: function (listview) {
frappe.require("logtypes.bundle.js", () => {
frappe.utils.logtypes.show_log_retention_message(cur_list.doctype);
});
},
};

View file

@ -8,7 +8,6 @@ import re
import frappe
from frappe import _, is_whitelisted
from frappe.permissions import has_permission
from frappe.translate import get_translated_doctypes
from frappe.utils import cint, cstr, unique
@ -150,10 +149,6 @@ def search_widget(
filters = []
or_filters = []
translated_doctypes = frappe.cache().hget(
"translated_doctypes", "doctypes", get_translated_doctypes
)
# build from doctype
if txt:
field_types = [
@ -175,7 +170,7 @@ def search_widget(
for f in search_fields:
fmeta = meta.get_field(f.strip())
if (doctype not in translated_doctypes) and (
if not meta.translated_doctype and (
f == "name" or (fmeta and fmeta.fieldtype in field_types)
):
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])
@ -191,26 +186,25 @@ def search_widget(
fields = list(set(fields + json.loads(filter_fields)))
formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields]
title_field_query = get_title_field_query(meta)
# Insert title field query after name
if title_field_query:
formatted_fields.insert(1, title_field_query)
# find relevance as location of search term from the beginning of string `name`. used for sorting results.
formatted_fields.append(
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")),
doctype=doctype,
)
)
if meta.show_title_field_in_link:
formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`")
# In order_by, `idx` gets second priority, because it stores link count
from frappe.model.db_query import get_order_by
order_by_based_on_meta = get_order_by(doctype, meta)
# 2 is the index of _relevance column
order_by = f"_relevance, {order_by_based_on_meta}, `tab{doctype}`.idx desc"
order_by = f"{order_by_based_on_meta}, `tab{doctype}`.idx desc"
if not meta.translated_doctype:
formatted_fields.append(
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")),
doctype=doctype,
)
)
order_by = f"_relevance, {order_by}"
ptype = "select" if frappe.only_has_select_perm(doctype) else "read"
ignore_permissions = (
@ -219,16 +213,13 @@ def search_widget(
else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype))
)
if doctype in translated_doctypes:
page_length = None
values = frappe.get_list(
doctype,
filters=filters,
fields=formatted_fields,
or_filters=or_filters,
limit_start=start,
limit_page_length=page_length,
limit_page_length=None if meta.translated_doctype else page_length,
order_by=order_by,
ignore_permissions=ignore_permissions,
reference_doctype=reference_doctype,
@ -236,12 +227,15 @@ def search_widget(
strict=False,
)
if doctype in translated_doctypes:
if meta.translated_doctype:
# Filtering the values array so that query is included in very element
values = (
v
for v in values
if re.search(f"{re.escape(txt)}.*", _(v.name if as_dict else v[0]), re.IGNORECASE)
result
for result in values
if any(
re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE)
for value in (result.values() if as_dict else result)
)
)
# Sorting the values array so that relevant results always come first
@ -250,12 +244,14 @@ def search_widget(
values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict))
# remove _relevance from results
if as_dict:
for r in values:
r.pop("_relevance")
frappe.response["values"] = values
else:
frappe.response["values"] = [r[:-1] for r in values]
if not meta.translated_doctype:
if as_dict:
for r in values:
r.pop("_relevance")
else:
values = [r[:-1] for r in values]
frappe.response["values"] = values
def get_std_fields_list(meta, key):
@ -275,39 +271,23 @@ def get_std_fields_list(meta, key):
return sflist
def get_title_field_query(meta):
title_field = meta.title_field if meta.title_field else None
show_title_field_in_link = (
meta.show_title_field_in_link if meta.show_title_field_in_link else None
)
field = None
def build_for_autosuggest(res: list[tuple], doctype: str) -> list[dict]:
def to_string(parts):
return ", ".join(
unique(_(cstr(part)) if meta.translated_doctype else cstr(part) for part in parts if part)
)
if title_field and show_title_field_in_link:
field = f"`tab{meta.name}`.{title_field} as `label`"
return field
def build_for_autosuggest(res, doctype):
results = []
meta = frappe.get_meta(doctype)
if not (meta.title_field and meta.show_title_field_in_link):
for r in res:
r = list(r)
results.append({"value": r[0], "description": ", ".join(unique(cstr(d) for d in r[1:] if d))})
if meta.show_title_field_in_link:
for item in res:
item = list(item)
label = item[1] # use title as label
item[1] = item[0] # show name in description instead of title
del item[2] # remove redundant title ("label") value
results.append({"value": item[0], "label": label, "description": to_string(item[1:])})
else:
title_field_exists = meta.title_field and meta.show_title_field_in_link
_from = 2 if title_field_exists else 1 # to exclude title from description if title_field_exists
for r in res:
r = list(r)
results.append(
{
"value": r[0],
"label": r[1] if title_field_exists else None,
"description": ", ".join(unique(cstr(d) for d in r[_from:] if d)),
}
)
results.extend({"value": item[0], "description": to_string(item[1:])} for item in res)
return results
@ -383,7 +363,7 @@ def get_user_groups():
def get_link_title(doctype, docname):
meta = frappe.get_meta(doctype)
if meta.title_field and meta.show_title_field_in_link:
if meta.show_title_field_in_link:
return frappe.db.get_value(doctype, docname, meta.title_field)
return docname

View file

@ -222,6 +222,7 @@ scheduler_events = {
"frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails",
"frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports",
"frappe.core.doctype.log_settings.log_settings.run_log_clean_up",
"frappe.utils.subscription.enable_manage_subscription",
],
"daily_long": [
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily",

View file

@ -517,10 +517,10 @@ def google_calendar_to_repeat_on(start, end, recurrence=None):
repeat_on = {
"starts_on": get_datetime(start.get("date"))
if start.get("date")
else parser.parse(start.get("dateTime")).utcnow(),
else parser.parse(start.get("dateTime")).astimezone().replace(tzinfo=None),
"ends_on": get_datetime(end.get("date"))
if end.get("date")
else parser.parse(end.get("dateTime")).utcnow(),
else parser.parse(end.get("dateTime")).astimezone().replace(tzinfo=None),
"all_day": 1 if start.get("date") else 0,
"repeat_this_event": 1 if recurrence else 0,
"repeat_on": None,

View file

@ -152,8 +152,9 @@ class BaseDocument:
if "name" in d:
self.name = d["name"]
ignore_children = hasattr(self, "flags") and self.flags.ignore_children
for key, value in d.items():
self.set(key, value)
self.set(key, value, as_value=ignore_children)
return self
@ -1174,7 +1175,10 @@ class BaseDocument:
# get values from old doc
if self.get("parent_doc"):
parent_doc = self.parent_doc.get_latest()
ref_doc = [d for d in parent_doc.get(self.parentfield) if d.name == self.name][0]
child_docs = [d for d in parent_doc.get(self.parentfield) if d.name == self.name]
if not child_docs:
return
ref_doc = child_docs[0]
else:
ref_doc = self.get_latest()

View file

@ -133,6 +133,7 @@ class Document(BaseDocument):
def load_from_db(self):
"""Load document and children from database and create properties
from fields"""
self.flags.ignore_children = True
if not getattr(self, "_metaclass", False) and self.meta.issingle:
single_doc = frappe.db.get_singles_dict(self.doctype, for_update=self.flags.for_update)
if not single_doc:
@ -154,6 +155,7 @@ class Document(BaseDocument):
)
super().__init__(d)
self.flags.pop("ignore_children", None)
for df in self._get_table_fields():
# Make sure not to query the DB for a child table, if it is a virtual one.

View file

@ -273,8 +273,8 @@ def make_autoname(key="", doctype="", doc=""):
*Example:*
* DE/./.YY./.MM./.##### will create a series like
DE/09/01/0001 where 09 is the year, 01 is the month and 0001 is the series
* DE./.YY./.MM./.##### will create a series like
DE/09/01/00001 where 09 is the year, 01 is the month and 00001 is the series
"""
if key == "hash":
return frappe.generate_hash(doctype, 10)

View file

@ -175,6 +175,7 @@ def execute_patch(patchmodule, method=None, methodargs=None):
start_time = time.time()
frappe.db.begin()
frappe.db.auto_commit_on_many_writes = 0
try:
if patchmodule:
if patchmodule.startswith("finally:"):

View file

@ -18,11 +18,12 @@ if click_ctx:
class ParallelTestRunner:
def __init__(self, app, site, build_number=1, total_builds=1):
def __init__(self, app, site, build_number=1, total_builds=1, dry_run=False):
self.app = app
self.site = site
self.build_number = frappe.utils.cint(build_number) or 1
self.total_builds = frappe.utils.cint(total_builds)
self.dry_run = dry_run
self.setup_test_site()
self.run_tests()
@ -31,6 +32,9 @@ class ParallelTestRunner:
if not frappe.db:
frappe.connect()
if self.dry_run:
return
frappe.flags.in_test = True
frappe.clear_cache()
frappe.utils.scheduler.disable_scheduler()
@ -64,6 +68,10 @@ class ParallelTestRunner:
if not file_info:
return
if self.dry_run:
print("running tests from", "/".join(file_info))
return
frappe.set_user("Administrator")
path, filename = file_info
module = self.get_module(path, filename)
@ -108,12 +116,48 @@ class ParallelTestRunner:
sys.exit(1)
def get_test_file_list(self):
# Load balance based on total # of tests ~ each runner should get roughly same # of tests.
test_list = get_all_tests(self.app)
split_size = frappe.utils.ceil(len(test_list) / self.total_builds)
# [1,2,3,4,5,6] to [[1,2], [3,4], [4,6]] if split_size is 2
test_chunks = [test_list[x : x + split_size] for x in range(0, len(test_list), split_size)]
test_counts = [self.get_test_count(test) for test in test_list]
test_chunks = split_by_weight(test_list, test_counts, chunk_count=self.total_builds)
return test_chunks[self.build_number - 1]
@staticmethod
def get_test_count(test):
"""Get approximate count of tests inside a file"""
file_name = "/".join(test)
with open(file_name) as f:
test_count = f.read().count("def test_")
return test_count
def split_by_weight(work, weights, chunk_count):
"""Roughly split work by respective weight while keep ordering."""
expected_weight = sum(weights) // chunk_count
chunks = [[] for _ in range(chunk_count)]
chunk_no = 0
chunk_weight = 0
for task, weight in zip(work, weights):
if chunk_weight > expected_weight:
chunk_weight = 0
chunk_no += 1
assert chunk_no < chunk_count
chunks[chunk_no].append(task)
chunk_weight += weight
assert len(work) == sum(len(chunk) for chunk in chunks)
assert len(chunks) == chunk_count
return chunks
class ParallelTestResult(unittest.TextTestResult):
def startTest(self, test):

View file

@ -213,3 +213,4 @@ frappe.patches.v14_0.update_multistep_webforms
execute:frappe.delete_doc('Page', 'background_jobs', ignore_missing=True, force=True)
frappe.patches.v14_0.drop_unused_indexes
frappe.patches.v15_0.drop_modified_index
frappe.patches.v14_0.add_manage_subscriptions_in_navbar_settings

View file

@ -0,0 +1,25 @@
import frappe
def execute():
navbar_settings = frappe.get_single("Navbar Settings")
if frappe.db.exists("Navbar Item", {"item_label": "Manage Subscriptions"}):
return
for idx, row in enumerate(navbar_settings.settings_dropdown[2:], start=4):
row.idx = idx
navbar_settings.append(
"settings_dropdown",
{
"item_label": "Manage Subscriptions",
"item_type": "Action",
"action": "frappe.ui.toolbar.redirectToUrl()",
"is_standard": 1,
"hidden": 1,
"idx": 3,
},
)
navbar_settings.save()

View file

@ -1,6 +1,7 @@
import frappe
import json
import frappe
def execute():
if frappe.db.exists("Social Login Key", "github"):

View file

@ -83,6 +83,7 @@ import "./frappe/ui/toolbar/search_utils.js";
import "./frappe/ui/toolbar/about.js";
import "./frappe/ui/toolbar/navbar.html";
import "./frappe/ui/toolbar/toolbar.js";
import "./frappe/ui/toolbar/subscription.js";
// import "./frappe/ui/toolbar/notifications.js";
import "./frappe/views/communication.js";
import "./frappe/views/translation_manager.js";

View file

@ -1,7 +1,6 @@
import "./jquery-bootstrap";
import "./libs.bundle.js";
import "./frappe/class.js";
import "./frappe/polyfill.js";
import "./lib/moment.js";
import "./frappe/provide.js";
import "./frappe/translate.js";
import "./frappe/form/formatters.js";

View file

@ -9,47 +9,46 @@
</div>
</div>
</template>
<script>
export default {
name: "BuildError",
data() {
return {
data: null
};
},
methods: {
show(data) {
this.data = data;
},
hide() {
this.data = null;
},
open_in_editor(location) {
frappe.socketio.socket.emit("open_in_editor", location);
},
error_component(error, i) {
let location = this.data.error.errors[i].location;
let location_string = `${location.file}:${location.line}:${
location.column
}`;
let template = error.replace(
" > " + location_string,
` &gt; <a class="file-link" @click="open">${location_string}</a>`
);
return {
template: `<div>${template}</div>`,
methods: {
open() {
frappe.socketio.socket.emit("open_in_editor", location);
}
}
};
<script setup>
import { ref } from "vue";
// variables
let data = ref(null);
// Methods
function show(data) {
data.value = data;
}
function hide() {
data.value = null;
}
function open_in_editor(location) {
frappe.socketio.socket.emit("open_in_editor", location);
}
function error_component(error, i) {
let location = data.value.error.errors[i].location;
let location_string = `${location.file}:${location.line}:${
location.column
}`;
let template = error.replace(
" > " + location_string,
` &gt; <a class="file-link" @click="open">${location_string}</a>`
);
return {
template: `<div>${template}</div>`,
methods: {
open() {
frappe.socketio.socket.emit("open_in_editor", location);
}
}
}
};
};
}
defineExpose({show, hide});
</script>
<style>
<style scoped>
.build-error-overlay {
position: fixed;
top: 0;

View file

@ -12,40 +12,41 @@
</a>
</div>
</template>
<script>
export default {
name: "BuildSuccess",
data() {
return {
is_shown: false,
live_reload: false,
};
},
methods: {
show(data) {
if (data.live_reload) {
this.live_reload = true;
this.reload();
}
this.is_shown = true;
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
this.hide();
}, 10000);
},
hide() {
this.is_shown = false;
},
reload() {
window.location.reload();
}
<script setup>
import { ref } from "vue";
// variables
let is_shown = ref(false);
let live_reload = ref(false);
let timeout = ref(null);
// Methods
function show(data) {
if (data.live_reload) {
live_reload.value = true;
reload();
}
};
is_shown.value = true;
if (timeout.value) {
clearTimeout(timeout.value);
}
timeout.value = setTimeout(() => {
hide();
}, 10000);
}
function hide() {
is_shown.value = false;
}
function reload() {
window.location.reload();
}
defineExpose({show, hide});
</script>
<style>
<style scoped>
.build-success-message {
position: fixed;
z-index: 9999;

View file

@ -1,3 +1,4 @@
import { createApp } from "vue";
import BuildError from "./BuildError.vue";
import BuildSuccess from "./BuildSuccess.vue";
@ -48,11 +49,7 @@ function show_build_success(data) {
if (!success) {
let target = $('<div class="build-success-container">').appendTo($container).get(0);
let vm = new Vue({
el: target,
render: (h) => h(BuildSuccess),
});
success = vm.$children[0];
success = createApp(BuildSuccess).mount(target);
}
success.show(data);
}
@ -63,11 +60,7 @@ function show_build_error(data) {
}
if (!error) {
let target = $('<div class="build-error-container">').appendTo($container).get(0);
let vm = new Vue({
el: target,
render: (h) => h(BuildError),
});
error = vm.$children[0];
error = createApp(BuildError).mount(target);
}
error.show(data);
}

View file

@ -43,7 +43,6 @@ frappe.Application = class Application {
throw "boot failed";
}
this.setup_frappe_vue();
this.load_bootinfo();
this.load_user_permissions();
this.make_nav_bar();
@ -183,11 +182,6 @@ frappe.Application = class Application {
});
}
setup_frappe_vue() {
Vue.prototype.__ = window.__;
Vue.prototype.frappe = window.frappe;
}
set_password(user) {
var me = this;
frappe.call({

View file

@ -4,7 +4,7 @@
<a
href=""
class="text-muted text-medium"
@click.prevent="$emit('hide-browser')"
@click.prevent="emit('hide-browser')"
>
{{ __("← Back to upload files") }}
</a>
@ -16,7 +16,7 @@
class="form-control input-xs"
:placeholder="__('Search by filename or extension')"
v-model="search_text"
@input="search_by_name"
@input="frappe.utils.debounce(search_by_name(), 300)"
/>
</div>
<TreeNode
@ -29,153 +29,154 @@
</div>
</div>
</template>
<script>
<script setup>
import { onMounted, ref } from "vue";
import TreeNode from "./TreeNode.vue";
export default {
name: "FileBrowser",
components: {
TreeNode
},
data() {
return {
node: {
label: __("Home"),
value: "Home",
children: [],
children_start: 0,
children_loading: false,
is_leaf: false,
fetching: false,
fetched: false,
open: false,
filtered: true
},
selected_node: {},
search_text: "",
page_length: 10
};
},
mounted() {
this.toggle_node(this.node);
},
methods: {
toggle_node(node) {
if (!node.fetched && !node.is_leaf) {
node.fetching = true;
node.children_start = 0;
// emits
let emit = defineEmits(["hide-browser"]);
// variables
let node = ref({
label: __("Home"),
value: "Home",
children: [],
children_start: 0,
children_loading: false,
is_leaf: false,
fetching: false,
fetched: false,
open: false,
filtered: true
});
let selected_node = ref({});
let search_text = ref("");
let page_length = ref(10);
let folder_node = ref(null);
// methods
function toggle_node(node) {
if (!node.fetched && !node.is_leaf) {
node.fetching = true;
node.children_start = 0;
node.children_loading = false;
get_files_in_folder(node.value, 0).then(
({ files, has_more }) => {
node.open = true;
node.children = files;
node.fetched = true;
node.fetching = false;
node.children_start += page_length.value;
node.has_more_children = has_more;
}
);
} else {
node.open = !node.open;
select_node(node);
}
}
function load_more(node) {
if (node.has_more_children) {
let start = node.children_start;
node.children_loading = true;
get_files_in_folder(node.value, start).then(
({ files, has_more }) => {
node.children = node.children.concat(files);
node.children_start += page_length.value;
node.has_more_children = has_more;
node.children_loading = false;
this.get_files_in_folder(node.value, 0).then(
({ files, has_more }) => {
node.open = true;
node.children = files;
node.fetched = true;
node.fetching = false;
node.children_start += this.page_length;
node.has_more_children = has_more;
}
);
} else {
node.open = !node.open;
this.select_node(node);
}
},
load_more(node) {
if (node.has_more_children) {
let start = node.children_start;
node.children_loading = true;
this.get_files_in_folder(node.value, start).then(
({ files, has_more }) => {
node.children = node.children.concat(files);
node.children_start += this.page_length;
node.has_more_children = has_more;
node.children_loading = false;
}
);
);
}
}
function select_node(node) {
if (node.is_leaf) {
selected_node.value = node;
}
}
function get_files_in_folder(folder, start) {
return frappe
.call("frappe.core.api.file.get_files_in_folder", {
folder,
start,
page_length: page_length.value
})
.then(r => {
let { files = [], has_more = false } = r.message || {};
files.sort((a, b) => {
if (a.is_folder && b.is_folder) {
return a.modified < b.modified ? -1 : 1;
}
if (a.is_folder) {
return -1;
}
if (b.is_folder) {
return 1;
}
return 0;
});
files = files.map(file => make_file_node(file));
return { files, has_more };
});
}
function search_by_name() {
if (search_text.value === "") {
node.value = folder_node.value;
return;
}
if (search_text.value.length < 3) return;
frappe
.call(
"frappe.core.api.file.get_files_by_search_text",
{
text: search_text.value
}
},
select_node(node) {
if (node.is_leaf) {
this.selected_node = node;
)
.then(r => {
let files = r.message || [];
files = files.map(file => make_file_node(file));
if (!folder_node.value) {
folder_node.value = node.value;
}
},
get_files_in_folder(folder, start) {
return frappe
.call("frappe.core.api.file.get_files_in_folder", {
folder,
start,
page_length: this.page_length
})
.then(r => {
let { files = [], has_more = false } = r.message || {};
files.sort((a, b) => {
if (a.is_folder && b.is_folder) {
return a.modified < b.modified ? -1 : 1;
}
if (a.is_folder) {
return -1;
}
if (b.is_folder) {
return 1;
}
return 0;
});
files = files.map(file => this.make_file_node(file));
return { files, has_more };
});
},
search_by_name: frappe.utils.debounce(function() {
if (this.search_text === "") {
this.node = this.folder_node;
return;
}
if (this.search_text.length < 3) return;
frappe
.call(
"frappe.core.api.file.get_files_by_search_text",
{
text: this.search_text
}
)
.then(r => {
let files = r.message || [];
files = files.map(file => this.make_file_node(file));
if (!this.folder_node) {
this.folder_node = this.node;
}
this.node = {
label: __("Search Results"),
value: "",
children: files,
by_search: true,
open: true,
filtered: true
};
});
}, 300),
make_file_node(file) {
let filename = file.file_name || file.name;
let label = frappe.utils.file_name_ellipsis(filename, 40);
return {
label: label,
filename: filename,
file_url: file.file_url,
value: file.name,
is_leaf: !file.is_folder,
fetched: !file.is_folder, // fetched if node is leaf
children: [],
children_loading: false,
children_start: 0,
open: false,
fetching: false,
node.value = {
label: __("Search Results"),
value: "",
children: files,
by_search: true,
open: true,
filtered: true
};
}
}
};
});
}
function make_file_node(file) {
let filename = file.file_name || file.name;
let label = frappe.utils.file_name_ellipsis(filename, 40);
return {
label: label,
filename: filename,
file_url: file.file_url,
value: file.name,
is_leaf: !file.is_folder,
fetched: !file.is_folder, // fetched if node is leaf
children: [],
children_loading: false,
children_start: 0,
open: false,
fetching: false,
filtered: true
};
}
// mounted
onMounted(() => {
toggle_node(node.value);
});
defineExpose({ selected_node });
</script>
<style>
<style scoped>
.file-browser-list {
height: 300px;
overflow: hidden;

View file

@ -12,20 +12,20 @@
<div>
<div>
<a class="flex" :href="file.doc.file_url" v-if="file.doc" target="_blank">
<span class="file-name">{{ file.name | file_name }}</span>
<span class="file-name">{{ file.name }}</span>
</a>
<span class="file-name" v-else>{{ file.name | file_name }}</span>
<span class="file-name" v-else>{{ file.name }}</span>
</div>
<div>
<span class="file-size">
{{ file.file_obj.size | file_size }}
{{ file_size }}
</span>
</div>
<div class="flex config-area">
<label v-if="is_optimizable" class="frappe-checkbox"><input type="checkbox" :checked="optimize" @change="$emit('toggle_optimize')">Optimize</label>
<label class="frappe-checkbox"><input type="checkbox" :checked="file.private" @change="$emit('toggle_private')">Private</label>
<label v-if="is_optimizable" class="frappe-checkbox"><input type="checkbox" :checked="optimize" @change="emit('toggle_optimize')">Optimize</label>
<label class="frappe-checkbox"><input type="checkbox" :checked="file.private" @change="emit('toggle_private')">Private</label>
</div>
<div>
<span v-if="file.error_message" class="file-error text-danger">
@ -45,75 +45,71 @@
<div v-if="uploaded" v-html="frappe.utils.icon('solid-success', 'lg')"></div>
<div v-if="file.failed" v-html="frappe.utils.icon('solid-error', 'lg')"></div>
<div class="file-action-buttons">
<button v-if="is_cropable" class="btn btn-crop muted" @click="$emit('toggle_image_cropper')" v-html="frappe.utils.icon('crop', 'md')"></button>
<button v-if="!uploaded && !file.uploading && !file.failed" class="btn muted" @click="$emit('remove')" v-html="frappe.utils.icon('delete', 'md')"></button>
<button v-if="is_cropable" class="btn btn-crop muted" @click="emit('toggle_image_cropper')" v-html="frappe.utils.icon('crop', 'md')"></button>
<button v-if="!uploaded && !file.uploading && !file.failed" class="btn muted" @click="emit('remove')" v-html="frappe.utils.icon('delete', 'md')"></button>
</div>
</div>
</div>
</template>
<script>
import ProgressRing from './ProgressRing.vue';
export default {
name: 'FilePreview',
props: ['file'],
components: {
ProgressRing
},
data() {
return {
src: null,
optimize: this.file.optimize
}
},
mounted() {
if (this.is_image) {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => this.src = fr.result;
fr.readAsDataURL(this.file.file_obj);
}
}
},
filters: {
file_size(value) {
return frappe.form.formatters.FileSize(value);
},
file_name(value) {
return value;
// return frappe.utils.file_name_ellipsis(value, 9);
}
},
computed: {
is_private() {
return this.file.doc ? this.file.doc.is_private : this.file.private;
},
uploaded() {
return this.file.request_succeeded;
},
is_image() {
return this.file.file_obj.type.startsWith('image');
},
is_optimizable() {
let is_svg = this.file.file_obj.type == 'image/svg+xml';
return this.is_image && !is_svg && !this.uploaded && !this.file.failed;
},
is_cropable() {
let croppable_types = ['image/jpeg', 'image/png'];
return !this.uploaded && !this.file.uploading && !this.file.failed && croppable_types.includes(this.file.file_obj.type);
},
progress() {
let value = Math.round((this.file.progress * 100) / this.file.total);
if (isNaN(value)) {
value = 0;
}
return value;
<script setup>
import { ref, onMounted, computed } from "vue";
import ProgressRing from "./ProgressRing.vue";
// emits
let emit = defineEmits(["toggle_optimize", "toggle_private", "toggle_image_cropper", "remove"]);
// props
let props = defineProps({
file: Object,
});
// variables
let src = ref(null);
let optimize = ref(props.file.optimize);
// computed
let file_size = computed(() => {
return frappe.form.formatters.FileSize(props.file.file_obj.size);
});
let is_private = computed(() => {
return props.file.doc ? props.file.doc.is_private : props.file.private;
});
let uploaded = computed(() => {
return props.file.request_succeeded;
});
let is_image = computed(() => {
return props.file.file_obj.type.startsWith('image');
});
let is_optimizable = computed(() => {
let is_svg = props.file.file_obj.type == 'image/svg+xml';
return is_image.value && !is_svg && !uploaded.value && !props.file.failed;
});
let is_cropable = computed(() => {
let croppable_types = ['image/jpeg', 'image/png'];
return !uploaded.value && !props.file.uploading && !props.file.failed && croppable_types.includes(props.file.file_obj.type);
});
let progress = computed(() => {
let value = Math.round((props.file.progress * 100) / props.file.total);
if (isNaN(value)) {
value = 0;
}
return value;
});
// mounted
onMounted(() => {
if (is_image.value) {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => src.value = fr.result;
fr.readAsDataURL(props.file.file_obj);
}
}
}
});
</script>
<style>
<style scoped>
.file-preview {
display: flex;
align-items: center;

View file

@ -127,476 +127,479 @@
</div>
</template>
<script>
<script setup>
import { computed, ref, watch } from 'vue';
import FilePreview from './FilePreview.vue';
import FileBrowser from './FileBrowser.vue';
import WebLink from './WebLink.vue';
import GoogleDrivePicker from '../../integrations/google_drive_picker';
import ImageCropper from './ImageCropper.vue';
export default {
name: 'FileUploader',
props: {
show_upload_button: {
default: true
},
disable_file_browser: {
default: false
},
allow_multiple: {
default: true
},
as_dataurl: {
default: false
},
doctype: {
default: null
},
docname: {
default: null
},
fieldname: {
default: null
},
folder: {
default: 'Home'
},
method: {
default: null
},
on_success: {
default: null
},
make_attachments_public: {
default: null,
},
restrictions: {
default: () => ({
max_file_size: null, // 2048 -> 2KB
max_number_of_files: null,
allowed_file_types: [], // ['image/*', 'video/*', '.jpg', '.gif', '.pdf'],
crop_image_aspect_ratio: null // 1, 16 / 9, 4 / 3, NaN (free)
})
},
attach_doc_image: {
default: false
},
upload_notes: {
default: null // "Images or video, upto 2MB"
}
// props
const props = defineProps({
show_upload_button: {
default: true
},
components: {
FilePreview,
FileBrowser,
WebLink,
ImageCropper
disable_file_browser: {
default: false
},
data() {
return {
files: [],
is_dragging: false,
currently_uploading: -1,
show_file_browser: false,
show_web_link: false,
show_image_cropper: false,
crop_image_with_index: -1,
trigger_upload: false,
close_dialog: false,
hide_dialog_footer: false,
allow_take_photo: false,
allow_web_link: true,
google_drive_settings: {
enabled: false
},
wrapper_ready: false
}
allow_multiple: {
default: true
},
created() {
this.allow_take_photo = window.navigator.mediaDevices;
if (frappe.user_id !== "Guest") {
frappe.call({
// method only available after login
method: "frappe.integrations.doctype.google_settings.google_settings.get_file_picker_settings",
callback: (resp) => {
if (!resp.exc) {
this.google_drive_settings = resp.message;
}
}
});
}
if (this.restrictions.max_file_size == null) {
frappe.call('frappe.core.api.file.get_max_file_size')
.then(res => {
this.restrictions.max_file_size = Number(res.message);
});
}
if (this.restrictions.max_number_of_files == null && this.doctype) {
this.restrictions.max_number_of_files = frappe.get_meta(this.doctype)?.max_attachments;
}
as_dataurl: {
default: false
},
watch: {
files(newvalue, oldvalue) {
if (!this.allow_multiple && newvalue.length > 1) {
this.files = [newvalue[newvalue.length - 1]];
doctype: {
default: null
},
docname: {
default: null
},
fieldname: {
default: null
},
folder: {
default: 'Home'
},
method: {
default: null
},
on_success: {
default: null
},
make_attachments_public: {
default: null,
},
restrictions: {
default: () => ({
max_file_size: null, // 2048 -> 2KB
max_number_of_files: null,
allowed_file_types: [], // ['image/*', 'video/*', '.jpg', '.gif', '.pdf'],
crop_image_aspect_ratio: null // 1, 16 / 9, 4 / 3, NaN (free)
})
},
attach_doc_image: {
default: false
},
upload_notes: {
default: null // "Images or video, upto 2MB"
}
});
// variables
let files = ref([]);
let file_input = ref(null);
let file_browser = ref(null);
let web_link = ref(null);
let is_dragging = ref(false);
let currently_uploading = ref(-1);
let show_file_browser = ref(false);
let show_web_link = ref(false);
let show_image_cropper = ref(false);
let crop_image_with_index = ref(-1);
let trigger_upload = ref(false);
let close_dialog = ref(false);
let hide_dialog_footer = ref(false);
let allow_take_photo = ref(false);
let allow_web_link = ref(true);
let google_drive_settings = ref({
enabled: false
});
let wrapper_ready = ref(false);
// created
allow_take_photo.value = window.navigator.mediaDevices;
if (frappe.user_id !== "Guest") {
frappe.call({
// method only available after login
method: "frappe.integrations.doctype.google_settings.google_settings.get_file_picker_settings",
callback: (resp) => {
if (!resp.exc) {
google_drive_settings.value = resp.message;
}
}
},
computed: {
upload_complete() {
return this.files.length > 0
&& this.files.every(
file => file.total !== 0 && file.progress === file.total);
});
}
if (props.restrictions.max_file_size == null) {
frappe.call('frappe.core.api.file.get_max_file_size')
.then(res => {
props.restrictions.max_file_size = Number(res.message);
});
}
if (props.restrictions.max_number_of_files == null && props.doctype) {
props.restrictions.max_number_of_files = frappe.get_meta(props.doctype)?.max_attachments;
}
// methods
function dragover() {
is_dragging.value = true;
}
function dragleave() {
is_dragging.value = false;
}
function dropfiles(e) {
is_dragging.value = false;
add_files(e.dataTransfer.files);
}
function browse_files() {
file_input.value.click();
}
function on_file_input(e) {
add_files(file_input.value.files);
}
function remove_file(file) {
files.value = files.value.filter(f => f !== file);
}
function toggle_image_cropper(index) {
crop_image_with_index.value = show_image_cropper.value ? -1 : index;
hide_dialog_footer.value = !show_image_cropper.value;
show_image_cropper.value = !show_image_cropper.value;
}
function toggle_all_private() {
let flag;
let private_values = files.value.filter(file => file.private);
if (private_values.length < files.value.length) {
// there are some private and some public
// set all to private
flag = true;
} else {
// all are private, set all to public
flag = false;
}
files.value = files.value.map(file => {
file.private = flag;
return file;
});
}
function show_max_files_number_warning(file) {
console.warn(
`File skipped because it exceeds the allowed specified limit of ${max_number_of_files} uploads`,
file,
);
if (props.doctype) {
MSG = __('File "{0}" was skipped because only {1} uploads are allowed for DocType "{2}"', [file.name, max_number_of_files, props.doctype])
} else {
MSG = __('File "{0}" was skipped because only {1} uploads are allowed', [file.name, max_number_of_files])
}
frappe.show_alert({
message: MSG,
indicator: "orange",
});
}
function add_files(file_array) {
let _files = Array.from(file_array)
.filter(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: size_kb > 200 && is_image && !file.type.includes('svg'),
name: file.name,
doc: null,
progress: 0,
total: 0,
failed: false,
request_succeeded: false,
error_message: null,
uploading: false,
private: !props.make_attachments_public,
};
});
// pop extra files as per FileUploader.restrictions.max_number_of_files
max_number_of_files = props.restrictions.max_number_of_files;
if (max_number_of_files && _files.length > max_number_of_files) {
_files.slice(max_number_of_files).forEach(file => {
show_max_files_number_warning(file, props.doctype);
});
_files = _files.slice(0, max_number_of_files);
}
files.value = files.value.concat(_files);
// if only one file is allowed and crop_image_aspect_ratio is set, open cropper immediately
if (files.value.length === 1 && !props.allow_multiple && props.restrictions.crop_image_aspect_ratio != null) {
if (!files.value[0].file_obj.type.includes('svg')) {
toggle_image_cropper(0);
}
},
methods: {
dragover() {
this.is_dragging = true;
},
dragleave() {
this.is_dragging = false;
},
dropfiles(e) {
this.is_dragging = false;
this.add_files(e.dataTransfer.files);
},
browse_files() {
this.$refs.file_input.click();
},
on_file_input(e) {
this.add_files(this.$refs.file_input.files);
},
remove_file(file) {
this.files = this.files.filter(f => f !== file);
},
toggle_image_cropper(index) {
this.crop_image_with_index = this.show_image_cropper ? -1 : index;
this.hide_dialog_footer = !this.show_image_cropper;
this.show_image_cropper = !this.show_image_cropper;
},
toggle_all_private() {
let flag;
let private_values = this.files.filter(file => file.private);
if (private_values.length < this.files.length) {
// there are some private and some public
// set all to private
flag = true;
} else {
// all are private, set all to public
flag = false;
}
this.files = this.files.map(file => {
file.private = flag;
return file;
});
},
show_max_files_number_warning(file) {
console.warn(
`File skipped because it exceeds the allowed specified limit of ${max_number_of_files} uploads`,
file,
);
if (this.doctype) {
MSG = __('File "{0}" was skipped because only {1} uploads are allowed for DocType "{2}"', [file.name, max_number_of_files, this.doctype])
} else {
MSG = __('File "{0}" was skipped because only {1} uploads are allowed', [file.name, max_number_of_files])
}
frappe.show_alert({
message: MSG,
indicator: "orange",
});
},
add_files(file_array) {
let files = Array.from(file_array)
.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: size_kb > 200 && is_image && !file.type.includes('svg'),
name: file.name,
doc: null,
progress: 0,
total: 0,
failed: false,
request_succeeded: false,
error_message: null,
uploading: false,
private: !this.make_attachments_public,
};
});
// pop extra files as per FileUploader.restrictions.max_number_of_files
max_number_of_files = this.restrictions.max_number_of_files;
if (max_number_of_files && files.length > max_number_of_files) {
files.slice(max_number_of_files).forEach(file => {
this.show_max_files_number_warning(file, this.doctype);
});
files = files.slice(0, max_number_of_files);
}
this.files = this.files.concat(files);
// 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 is_correct_type = true;
let valid_file_size = true;
if (allowed_file_types && allowed_file_types.length) {
is_correct_type = allowed_file_types.some((type) => {
// is this is a mime-type
if (type.includes('/')) {
if (!file.type) return false;
return file.type.match(type);
}
// otherwise this is likely an extension
if (type[0] === '.') {
return file.name.endsWith(type);
}
return false;
});
}
if (max_file_size && file.size != null) {
valid_file_size = file.size < max_file_size;
}
if (!is_correct_type) {
console.warn('File skipped because of invalid file type', file);
frappe.show_alert({
message: __('File "{0}" was skipped because of invalid file type', [file.name]),
indicator: 'orange'
});
}
if (!valid_file_size) {
console.warn('File skipped because of invalid file size', file.size, file);
frappe.show_alert({
message: __('File "{0}" was skipped because size exceeds {1} MB', [file.name, max_file_size / (1024 * 1024)]),
indicator: 'orange'
});
}
return is_correct_type && valid_file_size;
},
upload_files() {
if (this.show_file_browser) {
return this.upload_via_file_browser();
}
if (this.show_web_link) {
return this.upload_via_web_link();
}
if (this.as_dataurl) {
return this.return_as_dataurl();
}
return frappe.run_serially(
this.files.map(
(file, i) =>
() => this.upload_file(file, i)
)
);
},
upload_via_file_browser() {
let selected_file = this.$refs.file_browser.selected_node;
if (!selected_file.value) {
frappe.msgprint(__('Click on a file to select it.'));
this.close_dialog = true;
return Promise.reject();
}
this.close_dialog = true;
return this.upload_file({
file_url: selected_file.file_url
});
},
upload_via_web_link() {
let file_url = this.$refs.web_link.url;
if (!file_url) {
frappe.msgprint(__('Invalid URL'));
this.close_dialog = true;
return Promise.reject();
}
file_url = decodeURI(file_url)
this.close_dialog = true;
return this.upload_file({
file_url
});
},
return_as_dataurl() {
let promises = this.files.map(file =>
frappe.dom.file_to_base64(file.file_obj)
.then(dataurl => {
file.dataurl = dataurl;
this.on_success && this.on_success(file);
})
);
this.close_dialog = true;
return Promise.all(promises);
},
upload_file(file, i) {
this.currently_uploading = i;
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener('loadstart', (e) => {
file.uploading = true;
})
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
file.progress = e.loaded;
file.total = e.total;
}
})
xhr.upload.addEventListener('load', (e) => {
file.uploading = false;
resolve();
})
xhr.addEventListener('error', (e) => {
file.failed = true;
reject();
})
xhr.onreadystatechange = () => {
if (xhr.readyState == XMLHttpRequest.DONE) {
if (xhr.status === 200) {
file.request_succeeded = true;
let r = null;
let file_doc = null;
try {
r = JSON.parse(xhr.responseText);
if (r.message.doctype === 'File') {
file_doc = r.message;
}
} catch(e) {
r = xhr.responseText;
}
file.doc = file_doc;
if (this.on_success) {
this.on_success(file_doc, r);
}
if (i == this.files.length - 1 && this.files.every(file => file.request_succeeded)) {
this.close_dialog = true;
}
} else if (xhr.status === 403) {
file.failed = true;
let response = JSON.parse(xhr.responseText);
file.error_message = `Not permitted. ${response._error_message || ''}`;
} else if (xhr.status === 413) {
file.failed = true;
file.error_message = 'Size exceeds the maximum allowed file size.';
} else {
file.failed = true;
file.error_message = xhr.status === 0 ? 'XMLHttpRequest Error' : `${xhr.status} : ${xhr.statusText}`;
let error = null;
try {
error = JSON.parse(xhr.responseText);
} catch(e) {
// pass
}
frappe.request.cleanup({}, error);
}
}
}
xhr.open('POST', '/api/method/upload_file', true);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Frappe-CSRF-Token', frappe.csrf_token);
let form_data = new FormData();
if (file.file_obj) {
form_data.append('file', file.file_obj, file.name);
}
form_data.append('is_private', +file.private);
form_data.append('folder', this.folder);
if (file.file_url) {
form_data.append('file_url', file.file_url);
}
if (file.file_name) {
form_data.append('file_name', file.file_name);
}
if (this.doctype && this.docname) {
form_data.append('doctype', this.doctype);
form_data.append('docname', this.docname);
}
if (this.fieldname) {
form_data.append('fieldname', this.fieldname);
}
if (this.method) {
form_data.append('method', this.method);
}
if (file.optimize) {
form_data.append('optimize', true);
}
if (this.attach_doc_image) {
form_data.append('max_width', 200);
form_data.append('max_height', 200);
}
xhr.send(form_data);
});
},
capture_image() {
const capture = new frappe.ui.Capture({
animate: false,
error: true
});
capture.show();
capture.submit(data_urls => {
data_urls.forEach(data_url => {
let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
this.url_to_file(data_url, filename, 'image/png').then((file) =>
this.add_files([file])
);
});
});
},
show_google_drive_picker() {
this.close_dialog = true;
let google_drive = new GoogleDrivePicker({
pickerCallback: data => this.google_drive_callback(data),
...this.google_drive_settings
});
google_drive.loadPicker();
},
google_drive_callback(data) {
if (data.action == google.picker.Action.PICKED) {
this.upload_file({
file_url: data.docs[0].url,
file_name: data.docs[0].name
});
} else if (data.action == google.picker.Action.CANCEL) {
cur_frm.attachments.new_attachment()
}
},
url_to_file(url, filename, mime_type) {
return fetch(url)
.then(res => res.arrayBuffer())
.then(buffer => new File([buffer], filename, { type: mime_type }));
},
}
}
function check_restrictions(file) {
let { max_file_size, allowed_file_types = [] } = props.restrictions;
let is_correct_type = true;
let valid_file_size = true;
if (allowed_file_types && allowed_file_types.length) {
is_correct_type = allowed_file_types.some((type) => {
// is this is a mime-type
if (type.includes('/')) {
if (!file.type) return false;
return file.type.match(type);
}
// otherwise this is likely an extension
if (type[0] === '.') {
return file.name.endsWith(type);
}
return false;
});
}
if (max_file_size && file.size != null) {
valid_file_size = file.size < max_file_size;
}
if (!is_correct_type) {
console.warn('File skipped because of invalid file type', file);
frappe.show_alert({
message: __('File "{0}" was skipped because of invalid file type', [file.name]),
indicator: 'orange'
});
}
if (!valid_file_size) {
console.warn('File skipped because of invalid file size', file.size, file);
frappe.show_alert({
message: __('File "{0}" was skipped because size exceeds {1} MB', [file.name, max_file_size / (1024 * 1024)]),
indicator: 'orange'
});
}
return is_correct_type && valid_file_size;
}
function upload_files() {
if (show_file_browser.value) {
return upload_via_file_browser();
}
if (show_web_link.value) {
return upload_via_web_link();
}
if (props.as_dataurl) {
return return_as_dataurl();
}
return frappe.run_serially(
files.value.map(
(file, i) =>
() => upload_file(file, i)
)
);
}
function upload_via_file_browser() {
let selected_file = file_browser.value.selected_node;
if (!selected_file.value) {
frappe.msgprint(__('Click on a file to select it.'));
close_dialog.value = true;
return Promise.reject();
}
close_dialog.value = true;
return upload_file({
file_url: selected_file.file_url
});
}
function upload_via_web_link() {
let file_url = web_link.value.url;
if (!file_url) {
frappe.msgprint(__('Invalid URL'));
close_dialog.value = true;
return Promise.reject();
}
file_url = decodeURI(file_url)
close_dialog.value = true;
return upload_file({
file_url
});
}
function return_as_dataurl() {
let promises = files.value.map(file =>
frappe.dom.file_to_base64(file.file_obj)
.then(dataurl => {
file.dataurl = dataurl;
props.on_success && props.on_success(file);
})
);
close_dialog.value = true;
return Promise.all(promises);
}
function upload_file(file, i) {
currently_uploading.value = i;
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener('loadstart', (e) => {
file.uploading = true;
})
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
file.progress = e.loaded;
file.total = e.total;
}
})
xhr.upload.addEventListener('load', (e) => {
file.uploading = false;
resolve();
})
xhr.addEventListener('error', (e) => {
file.failed = true;
reject();
})
xhr.onreadystatechange = () => {
if (xhr.readyState == XMLHttpRequest.DONE) {
if (xhr.status === 200) {
file.request_succeeded = true;
let r = null;
let file_doc = null;
try {
r = JSON.parse(xhr.responseText);
if (r.message.doctype === 'File') {
file_doc = r.message;
}
} catch(e) {
r = xhr.responseText;
}
file.doc = file_doc;
if (props.on_success) {
props.on_success(file_doc, r);
}
if (i == files.value.length - 1 && files.value.every(file => file.request_succeeded)) {
close_dialog.value = true;
}
} else if (xhr.status === 403) {
file.failed = true;
let response = JSON.parse(xhr.responseText);
file.error_message = `Not permitted. ${response._error_message || ''}`;
} else if (xhr.status === 413) {
file.failed = true;
file.error_message = 'Size exceeds the maximum allowed file size.';
} else {
file.failed = true;
file.error_message = xhr.status === 0 ? 'XMLHttpRequest Error' : `${xhr.status} : ${xhr.statusText}`;
let error = null;
try {
error = JSON.parse(xhr.responseText);
} catch(e) {
// pass
}
frappe.request.cleanup({}, error);
}
}
}
xhr.open('POST', '/api/method/upload_file', true);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Frappe-CSRF-Token', frappe.csrf_token);
let form_data = new FormData();
if (file.file_obj) {
form_data.append('file', file.file_obj, file.name);
}
form_data.append('is_private', +file.private);
form_data.append('folder', props.folder);
if (file.file_url) {
form_data.append('file_url', file.file_url);
}
if (file.file_name) {
form_data.append('file_name', file.file_name);
}
if (props.doctype && props.docname) {
form_data.append('doctype', props.doctype);
form_data.append('docname', props.docname);
}
if (props.fieldname) {
form_data.append('fieldname', props.fieldname);
}
if (props.method) {
form_data.append('method', props.method);
}
if (file.optimize) {
form_data.append('optimize', true);
}
if (props.attach_doc_image) {
form_data.append('max_width', 200);
form_data.append('max_height', 200);
}
xhr.send(form_data);
});
}
function capture_image() {
const capture = new frappe.ui.Capture({
animate: false,
error: true
});
capture.show();
capture.submit(data_urls => {
data_urls.forEach(data_url => {
let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
url_to_file(data_url, filename, 'image/png').then((file) =>
add_files([file])
);
});
});
}
function show_google_drive_picker() {
close_dialog.value = true;
let google_drive = new GoogleDrivePicker({
pickerCallback: data => google_drive_callback(data),
...google_drive_settings.value
});
google_drive.loadPicker();
}
function google_drive_callback(data) {
if (data.action == google.picker.Action.PICKED) {
upload_file({
file_url: data.docs[0].url,
file_name: data.docs[0].name
});
} else if (data.action == google.picker.Action.CANCEL) {
cur_frm.attachments.new_attachment()
}
}
function url_to_file(url, filename, mime_type) {
return fetch(url)
.then(res => res.arrayBuffer())
.then(buffer => new File([buffer], filename, { type: mime_type }));
}
// computed
let upload_complete = computed(() => {
return files.value.length > 0
&& files.value.every(
file => file.total !== 0 && file.progress === file.total);
});
// watcher
watch(files, (newvalue, oldvalue) => {
if (!props.allow_multiple && newvalue.length > 1) {
files.value = [newvalue[newvalue.length - 1]];
}
}, { deep: true });
defineExpose({
files,
add_files,
upload_files,
toggle_all_private,
wrapper_ready
});
</script>
<style>
<style scoped>
.file-upload-area {
min-height: 16rem;
display: flex;

View file

@ -1,7 +1,7 @@
<template>
<div>
<div>
<img ref="image" :src="src" :alt="file.name" />
<img ref="image_ref" :src="src" :alt="file.name" />
</div>
<div class="image-cropper-actions">
<div>
@ -25,7 +25,7 @@
<div>
<button
class="btn btn-sm margin-right"
@click="$emit('toggle_image_cropper')"
@click="emit('toggle_image_cropper')"
v-if="fixed_aspect_ratio == null"
>
{{ __("Back") }}
@ -38,86 +38,93 @@
</div>
</template>
<script>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import Cropper from "cropperjs";
export default {
name: "ImageCropper",
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,
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);
}
let crop_box = this.file.crop_box_data;
this.image = this.$refs.image;
this.image.onload = () => {
this.cropper = new Cropper(this.image, {
zoomable: false,
scalable: false,
viewMode: 1,
data: crop_box,
aspectRatio: this.aspect_ratio
});
window.cropper = this.cropper;
};
},
computed: {
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: {
crop_image() {
this.file.crop_box_data = this.cropper.getData();
const canvas = this.cropper.getCroppedCanvas();
const file_type = this.file.file_obj.type;
canvas.toBlob(blob => {
var cropped_file_obj = new File([blob], this.file.name, {
type: blob.type
});
this.file.file_obj = cropped_file_obj;
this.$emit("toggle_image_cropper");
}, file_type);
}
// props
let props = defineProps({
file: Object,
fixed_aspect_ratio: Number,
});
// emits
let emit = defineEmits(["toggle_image_cropper"]);
// variables
let aspect_ratio = ref(props.fixed_aspect_ratio != null ? props.fixed_aspect_ratio : NaN);
let src = ref(null);
let cropper = ref(null);
let image = ref(null);
let image_ref = ref(null); // Template ref
// methods
function crop_image() {
props.file.crop_box_data = cropper.value.getData();
const canvas = cropper.value.getCroppedCanvas();
const file_type = props.file.file_obj.type;
canvas.toBlob(blob => {
var cropped_file_obj = new File([blob], props.file.name, {
type: blob.type
});
props.file.file_obj = cropped_file_obj;
emit("toggle_image_cropper");
}, file_type);
}
// mounted
onMounted(() => {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => (src.value = fr.result);
fr.readAsDataURL(props.file.cropper_file);
}
};
let crop_box = props.file.crop_box_data;
image.value = image_ref.value;
image.value.onload = () => {
cropper.value = new Cropper(image.value, {
zoomable: false,
scalable: false,
viewMode: 1,
data: crop_box,
aspectRatio: aspect_ratio.value
});
window.cropper = cropper.value;
};
});
// computed
let aspect_ratio_buttons = computed(() => {
return [
{
label: __("1:1"),
value: 1
},
{
label: __("4:3"),
value: 4 / 3
},
{
label: __("16:9"),
value: 16 / 9
},
{
label: __("Free"),
value: NaN
}
];
});
// watcher
watch(aspect_ratio, (value) => {
if (cropper.value) {
cropper.value.setAspectRatio(value);
}
}, { deep: true });
</script>
<style>
<style scoped>
img {
display: block;
max-width: 100%;

View file

@ -39,34 +39,30 @@
</text>
</svg>
</template>
<script>
export default {
name: "ProgressRing",
props: {
primary: String,
secondary: String,
radius: Number,
progress: Number,
stroke: Number
},
data() {
const normalizedRadius = this.radius - this.stroke * 2;
const circumference = normalizedRadius * 2 * Math.PI;
return {
normalizedRadius,
circumference
};
},
computed: {
strokeDashoffset() {
return (
this.circumference - (this.progress / 100) * this.circumference
);
}
}
};
<script setup>
import { computed, ref } from "vue";
// props
let props = defineProps({
primary: String,
secondary: String,
radius: Number,
progress: Number,
stroke: Number
});
// variables
let normalizedRadius = ref(props.radius - props.stroke * 2);
let circumference = ref(normalizedRadius.value * 2 * Math.PI);
// computed
let strokeDashoffset = computed(() => {
return circumference.value - (props.progress / 100) * circumference.value;
});
</script>
<style scoped>
circle {
transition: stroke-dashoffset 0.35s;

View file

@ -2,7 +2,7 @@
<div class="tree-node" :class="{ opened: node.open }">
<span
class="tree-link"
@click="$emit('node-click', node)"
@click="emit('node-click', node)"
:class="{ active: node.value === selected_node.value }"
:disabled="node.fetching"
>
@ -15,13 +15,13 @@
:key="n.value"
:node="n"
:selected_node="selected_node"
@node-click="n => $emit('node-click', n)"
@load-more="n => $emit('load-more', n)"
@node-click="n => emit('node-click', n)"
@load-more="n => emit('load-more', n)"
/>
<button
class="btn btn-xs btn-load-more"
v-if="node.has_more_children"
@click="$emit('load-more', node)"
@click="emit('load-more', node)"
:disabled="node.children_loading"
>
{{ node.children_loading ? __("Loading...") : __("Load more") }}
@ -29,30 +29,38 @@
</ul>
</div>
</template>
<script>
export default {
name: "TreeNode",
props: ["node", "selected_node"],
components: {
TreeNode: () => frappe.ui.components.TreeNode
},
computed: {
icon() {
let icons = {
open: frappe.utils.icon("folder-open", "md"),
closed: frappe.utils.icon("folder-normal", "md"),
leaf: frappe.utils.icon("primitive-dot", "xs"),
search: frappe.utils.icon("search")
};
if (this.node.by_search) return icons.search;
if (this.node.is_leaf) return icons.leaf;
if (this.node.open) return icons.open;
return icons.closed;
}
}
};
<script setup>
import TreeNode from "./TreeNode.vue";
import { computed } from "vue";
// props
let props = defineProps({
node: Object,
selected_node: Object,
});
// emits
let emit = defineEmits(["node-click", "load-more"]);
// computed
let icon = computed(() => {
let icons = {
open: frappe.utils.icon("folder-open", "md"),
closed: frappe.utils.icon("folder-normal", "md"),
leaf: frappe.utils.icon("primitive-dot", "xs"),
search: frappe.utils.icon("search")
};
if (props.node.by_search) return icons.search;
if (props.node.is_leaf) return icons.leaf;
if (props.node.open) return icons.open;
return icons.closed;
});
</script>
<style scoped>
.btn-load-more {
margin-left: 1.6rem;

View file

@ -1,7 +1,7 @@
<template>
<div class="file-web-link margin-bottom">
<a href class="text-muted text-medium"
@click.prevent="$emit('hide-web-link')"
@click.prevent="emit('hide-web-link')"
>
{{ __('← Back to upload files') }}
</a>
@ -15,18 +15,19 @@
</div>
</div>
</template>
<script>
export default {
name: 'WebLink',
data() {
return {
url: '',
}
}
}
<script setup>
import { ref } from "vue";
// emits
let emit = defineEmits(["hide-web-link"]);
let url = ref("");
defineExpose({ url });
</script>
<style>
<style scoped>
.file-web-link .input-group {
margin-top: 10px;
}

View file

@ -1,6 +1,7 @@
import { createApp } from "vue";
import FileUploaderComponent from "./FileUploader.vue";
export default class FileUploader {
class FileUploader {
constructor({
wrapper,
method,
@ -28,30 +29,24 @@ export default class FileUploader {
this.wrapper = wrapper.get ? wrapper.get(0) : wrapper;
}
this.$fileuploader = new Vue({
el: this.wrapper,
render: (h) =>
h(FileUploaderComponent, {
props: {
show_upload_button: !Boolean(this.dialog),
doctype,
docname,
fieldname,
method,
folder,
on_success,
restrictions,
upload_notes,
allow_multiple,
as_dataurl,
disable_file_browser,
attach_doc_image,
make_attachments_public,
},
}),
let app = createApp(FileUploaderComponent, {
show_upload_button: !Boolean(this.dialog),
doctype,
docname,
fieldname,
method,
folder,
on_success,
restrictions,
upload_notes,
allow_multiple,
as_dataurl,
disable_file_browser,
attach_doc_image,
make_attachments_public,
});
this.uploader = this.$fileuploader.$children[0];
SetVueGlobals(app);
this.uploader = app.mount(this.wrapper);
if (!this.dialog) {
this.uploader.wrapper_ready = true;
@ -125,3 +120,7 @@ export default class FileUploader {
});
}
}
frappe.provide("frappe.ui");
frappe.ui.FileUploader = FileUploader;
export default FileUploader;

View file

@ -38,6 +38,7 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
this.bind_leaflet_draw_control();
this.bind_leaflet_locate_control();
this.bind_leaflet_refresh_button();
this.map.setView(frappe.utils.map_defaults.center, frappe.utils.map_defaults.zoom);
}
format_for_input(value) {

View file

@ -89,10 +89,13 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
is_translatable() {
return in_list(frappe.boot?.translated_doctypes || [], this.get_options());
}
is_title_link() {
return in_list(frappe.boot.link_title_doctypes, this.get_options());
}
async set_link_title(value) {
const doctype = this.get_options();
if (!doctype || !in_list(frappe.boot.link_title_doctypes, doctype)) {
if (!doctype || !this.is_title_link()) {
this.translate_and_set_input_value(value, value);
return;
}
@ -166,7 +169,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
frappe.route_options.name_field = this.get_label_value();
// reference to calling link
frappe._from_link = this;
frappe._from_link = frappe.utils.deep_clone(this);
frappe._from_link_scrollY = $(document).scrollTop();
frappe.ui.form.make_quick_entry(doctype, (doc) => {
@ -207,7 +210,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
let _label = me.get_translated(d.label);
let html = d.html || "<strong>" + _label + "</strong>";
if (d.description && d.value !== d.description) {
if (
d.description &&
// for title links, we want to inlude the value in the description
// because it will not visible otherwise
(me.is_title_link() || d.value !== d.description)
) {
html += '<br><span class="small">' + __(d.description) + "</span>";
}
return $("<li></li>")

View file

@ -1011,6 +1011,7 @@ export default class Grid {
Int: (val) => cint(val),
Check: (val) => cint(val),
Float: (val) => flt(val),
Currency: (val) => flt(val),
};
// upload

View file

@ -167,13 +167,12 @@ frappe.ui.form.ScriptManager = class ScriptManager {
setup() {
const doctype = this.frm.meta;
const me = this;
let client_script;
let client_script = doctype.__js;
// process the custom script for this form
if (this.frm.doctype_layout) {
client_script = this.frm.doctype_layout.client_script;
} else {
client_script = doctype.__js;
// append the custom script for this form's layout
if (this.frm.doctype_layout?.client_script) {
// add a newline to avoid conflict with doctype JS
client_script += `\n${this.frm.doctype_layout.client_script}`;
}
if (client_script) {

View file

@ -77,12 +77,12 @@ frappe.views.BaseList = class BaseList {
.then((doc) => (this.list_view_settings = doc.message || {}));
}
setup_fields() {
this.set_fields();
async setup_fields() {
await this.set_fields();
this.build_fields();
}
set_fields() {
async set_fields() {
let fields = [].concat(frappe.model.std_fields_list, this.meta.title_field);
fields.forEach((f) => this._add_field(f));

View file

@ -25,6 +25,7 @@ export default class ListFilter {
this.$saved_filters = this.wrapper.find(".saved-filters").hide();
this.$saved_filters_preview = this.wrapper.find(".saved-filters-preview");
this.saved_filters_hidden = true;
this.toggle_saved_filters(true);
this.filter_input = frappe.ui.form.make_control({
df: {
@ -102,11 +103,18 @@ export default class ListFilter {
bind_remove_filter() {
this.wrapper.on("click", ".filter-pill .remove", (e) => {
const $li = $(e.currentTarget).closest(".filter-pill");
const name = $li.attr("data-name");
const applied_filters = this.get_filters_values(name);
$li.remove();
this.remove_filter(name).then(() => this.refresh());
this.list_view.filter_area.remove_filters(applied_filters);
const filter_label = $li.text().trim();
frappe.confirm(
__("Are you sure you want to remove the {0} filter?", [filter_label.bold()]),
() => {
const name = $li.attr("data-name");
const applied_filters = this.get_filters_values(name);
$li.remove();
this.remove_filter(name).then(() => this.refresh());
this.list_view.filter_area.remove_filters(applied_filters);
}
);
});
}

View file

@ -170,7 +170,18 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
);
}
set_fields() {
get_fields() {
return super
.get_fields()
.concat(
Object.entries(this.link_field_title_fields || {}).map(
(entry) => entry.join(".") + " as " + entry.join("_")
)
);
}
async set_fields() {
this.link_field_title_fields = {};
let fields = [].concat(
frappe.model.std_fields_list,
this.get_fields_in_list_view(),
@ -183,7 +194,34 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
"color"
);
fields.forEach((f) => this._add_field(f));
await Promise.all(
fields.map((f) => {
return new Promise((resolve) => {
const df =
typeof f === "string" ? frappe.meta.get_docfield(this.doctype, f) : f;
if (
df &&
df.fieldtype == "Link" &&
frappe.boot.link_title_doctypes.includes(df.options)
) {
frappe.model.with_doctype(df.options, () => {
const meta = frappe.get_meta(df.options);
if (meta.show_title_field_in_link) {
this.link_field_title_fields[
typeof f === "string" ? f : f.fieldname
] = meta.title_field;
}
this._add_field(f);
resolve();
});
} else {
this._add_field(f);
resolve();
}
});
})
);
this.fields.forEach((f) => {
const df = frappe.meta.get_docfield(f[1], f[0]);
@ -691,8 +729,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
const df = col.df || {};
const label = df.label;
const fieldname = df.fieldname;
const link_title_fieldname = this.link_field_title_fields[fieldname];
const value = doc[fieldname] || "";
const value_display = link_title_fieldname
? doc[fieldname + "_" + link_title_fieldname] || value
: value;
const format = () => {
if (df.fieldtype === "Code") {
return value;
@ -716,9 +757,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
(df.fetch_from && ["Text", "Small Text"].includes(df.fieldtype));
if (strip_html_required) {
_value = strip_html(value);
_value = strip_html(value_display);
} else {
_value = typeof value === "string" ? frappe.utils.escape_html(value) : value;
_value =
typeof value_display === "string"
? frappe.utils.escape_html(value_display)
: value_display;
}
if (df.fieldtype === "Rating") {

View file

@ -190,7 +190,12 @@ $.extend(frappe.model, {
} else if (default_val == "Today") {
value = frappe.datetime.get_today();
} else if ((default_val || "").toLowerCase() === "now") {
value = frappe.datetime.now_datetime();
if (df.fieldtype == "Time") {
value = frappe.datetime.now_time();
} else {
// datetime fields are stored in system tz
value = frappe.datetime.system_datetime();
}
} else if (default_val[0] === ":") {
var boot_doc = frappe.model.get_default_from_boot_docs(df, doc, parent_doc);
var is_allowed_boot_doc =

View file

@ -11,9 +11,18 @@
</div>
<div class="filter-edit-area"></div>
<div class="sort-selector">
<div class="dropdown"><a class="text-muted dropdown-toggle small" data-toggle="dropdown"><span class="dropdown-text">{{ columns.filter(c => c.slug == query.sort)[0].label }}</span></a>
<div class="dropdown">
<a class="text-muted dropdown-toggle small" data-toggle="dropdown">
<span class="dropdown-text">
{{ columns.filter(c => c.slug == query.sort)[0].label }}
</span>
</a>
<ul class="dropdown-menu">
<li v-for="(column, index) in columns.filter(c => c.sortable)" :key="index" @click="query.sort = column.slug"><a class="option">{{ column.label }}</a></li>
<li v-for="(column, index) in columns.filter(c => c.sortable)" :key="index" @click="query.sort = column.slug">
<a class="option">
{{ column.label }}
</a>
</li>
</ul>
</div>
<button class="btn btn-default btn-xs btn-order">
@ -71,7 +80,11 @@
</div>
<div v-if="requests.length == 0" class="no-result text-muted flex justify-center align-center" style="">
<div class="msg-box no-border" v-if="status.status == 'Inactive'" >
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">{{ __("Start Recording") }}</button></p>
<p>
<button class="btn btn-primary btn-sm btn-new-doc" @click="start()">
{{ __("Start Recording") }}
</button>
</p>
<p>{{ __("Recorder is Inactive.") }}</p>
<p>{{ __("Start recording or drag & drop a previously exported data file to view it.") }}</p>
</div>
@ -102,181 +115,192 @@
</div>
</template>
<script>
export default {
name: "RecorderDetail",
data() {
return {
requests: [],
columns: [
{label: __("Path"), slug: "path"},
{label: __("Duration (ms)"), slug: "duration", sortable: true, number: true},
{label: __("Time in Queries (ms)"), slug: "time_queries", sortable: true, number: true},
{label: __("Queries"), slug: "queries", sortable: true, number: true},
{label: __("Method"), slug: "method"},
{label: __("Time"), slug: "time", sortable: true},
],
query: {
sort: "duration",
order: "desc",
filters: {},
pagination: {
limit: 20,
page: 1,
total: 0,
}
},
status: {
color: "grey",
status: "Unknown",
},
};
},
created() {
let route = frappe.get_route();
if (route[2]) {
this.$router.push({name: 'request-detail', params: {id: route[2]}});
}
},
mounted() {
this.fetch_status();
this.refresh();
this.$root.page.set_secondary_action(__("Clear"), () => {
frappe.set_route("recorder");
this.clear();
});
this.$root.page.add_menu_item("Export data", () => this.export_data());
},
computed: {
pages: function() {
const current_page = this.query.pagination.page;
const total_pages = this.query.pagination.total;
return [{
label: __("First"),
number: 1,
status: (current_page == 1) ? "disabled" : "",
},{
label: __("Previous"),
number: Math.max(current_page - 1, 1),
status: (current_page == 1) ? "disabled" : "",
}, {
label: current_page,
number: current_page,
status: "btn-info",
}, {
label: __("Next"),
number: Math.min(current_page + 1, total_pages),
status: (current_page == total_pages) ? "disabled" : "",
}, {
label: __("Last"),
number: total_pages,
status: (current_page == total_pages) ? "disabled" : "",
}];
}
},
methods: {
filtered: function(requests) {
requests = requests.slice();
const filters = Object.entries(this.query.filters);
requests = requests.filter(
(r) => filters.map((f) => (r[f[0]] || "").match(f[1])).every(Boolean)
);
this.query.pagination.total = Math.ceil(requests.length / this.query.pagination.limit);
return requests;
},
paginated: function(requests) {
requests = requests.slice();
const begin = (this.query.pagination.page - 1) * (this.query.pagination.limit);
const end = begin + this.query.pagination.limit;
return requests.slice(begin, end);
},
sorted: function(requests) {
requests = requests.slice();
const order = (this.query.order == "asc") ? 1 : -1;
const sort = this.query.sort;
return requests.sort((a,b) => (a[sort] > b[sort]) ? order : -order);
},
refresh: function() {
frappe.call("frappe.recorder.get").then( r => this.requests = r.message);
},
update: function(message) {
this.requests.push(JSON.parse(message));
},
clear: function() {
frappe.call("frappe.recorder.delete").then(r => this.refresh());
},
start: function() {
frappe.call("frappe.recorder.start").then(r => this.fetch_status());
},
stop: function() {
frappe.call("frappe.recorder.stop").then(r => this.fetch_status());
},
fetch_status: function() {
frappe.call("frappe.recorder.status").then(r => this.update_status(r.message));
},
update_status: function(result) {
if(result) {
this.status = {status: "Active", color: "green"}
} else {
this.status = {status: "Inactive", color: "red"}
}
this.$root.page.set_indicator(this.status.status, this.status.color);
if(this.status.status == "Active") {
frappe.realtime.on("recorder-dump-event", this.update);
} else {
frappe.realtime.off("recorder-dump-event", this.update);
}
<script setup>
import { ref, computed, onMounted } from "vue"
import { useRouter } from "vue-router"
this.update_buttons();
},
update_buttons: function() {
if(this.status.status == "Active") {
this.$root.page.set_primary_action(__("Stop"), () => {
this.stop();
});
} else {
this.$root.page.set_primary_action(__("Start"), () => {
this.start();
});
}
},
route_to_request_detail(request) {
this.$router.push({name: 'request-detail', params: {request, id: request.uuid}});
},
export_data: function() {
if (!this.requests) {
return;
}
frappe.call("frappe.recorder.export_data")
.then((r) => {
const data = r.message;
const filename = `${data[0]['uuid']}..${data[data.length -1]['uuid']}.json`
// variables
let router = ref(useRouter());
let requests = ref([]);
let page = frappe.pages["recorder"].page;
const el = document.createElement('a');
el.setAttribute('href', 'data:application/json,' + encodeURIComponent(JSON.stringify(data)));
el.setAttribute('download', filename);
el.click();
});
},
import_data: function(e) {
if (this.requests.length > 0) {
// don't replace existing capture
return;
}
const request_file = e.dataTransfer.files[0];
let columns = [
{label: __("Path"), slug: "path"},
{label: __("Duration (ms)"), slug: "duration", sortable: true, number: true},
{label: __("Time in Queries (ms)"), slug: "time_queries", sortable: true, number: true},
{label: __("Queries"), slug: "queries", sortable: true, number: true},
{label: __("Method"), slug: "method"},
{label: __("Time"), slug: "time", sortable: true},
];
const file_reader = new FileReader();
file_reader.readAsText(request_file, 'UTF-8');
file_reader.onload = ({target: {result}}) => {
this.requests = JSON.parse(result);
}
}
let query = ref({
sort: "duration",
order: "desc",
filters: {},
pagination: {
limit: 20,
page: 1,
total: 0,
}
};
</script>
<style>
.list-row .level-left {
flex: 8;
width: 100%;
});
let status = ref({
color: "grey",
status: "Unknown",
});
// Started
frappe.recorder.router = router.value;
let route = frappe.get_route();
if (route[2]) {
router.value.push({name: "RequestDetail", params: {id: route[2]}});
}
// Methods
function filtered(reqs) {
reqs = reqs.slice();
const filters = Object.entries(query.value.filters);
reqs = reqs.filter(
(r) => filters.map((f) => (r[f[0]] || "").match(f[1])).every(Boolean)
);
query.value.pagination.total = Math.ceil(reqs.length / query.value.pagination.limit);
return reqs;
}
function paginated(reqs) {
reqs = reqs.slice();
const begin = (query.value.pagination.page - 1) * (query.value.pagination.limit);
const end = begin + query.value.pagination.limit;
return reqs.slice(begin, end);
}
function sorted(reqs) {
reqs = reqs.slice();
const order = (query.value.order == "asc") ? 1 : -1;
const sort = query.value.sort;
return reqs.sort((a,b) => (a[sort] > b[sort]) ? order : -order);
}
function refresh() {
frappe.call("frappe.recorder.get").then( r => requests.value = r.message);
}
function update(message) {
requests.value.push(JSON.parse(message));
}
function clear() {
frappe.call("frappe.recorder.delete").then(r => refresh());
}
function start() {
frappe.call("frappe.recorder.start").then(r => fetch_status());
}
function stop() {
frappe.call("frappe.recorder.stop").then(r => fetch_status());
}
function fetch_status() {
frappe.call("frappe.recorder.status").then(r => update_status(r.message));
}
function update_status(result) {
if(result) {
status.value = {status: "Active", color: "green"}
} else {
status.value = {status: "Inactive", color: "red"}
}
page.set_indicator(status.value.status, status.value.color);
if(status.value.status == "Active") {
frappe.realtime.on("recorder-dump-event", update);
} else {
frappe.realtime.off("recorder-dump-event", update);
}
update_buttons();
}
function update_buttons() {
if(status.value.status == "Active") {
page.set_primary_action(__("Stop"), () => {
stop();
});
} else {
page.set_primary_action(__("Start"), () => {
start();
});
}
}
function route_to_request_detail(request) {
router.value.beforeEach(async to => {
if (to.meta.shouldFetch) {
to.meta.request = await request
}
});
router.value.push({name: "RequestDetail", params: {id: request.uuid}});
}
function export_data() {
if (!requests.value) {
return;
}
frappe.call("frappe.recorder.export_data")
.then((r) => {
const data = r.message;
const filename = `${data[0]["uuid"]}..${data[data.length -1]["uuid"]}.json`
const el = document.createElement("a");
el.setAttribute("href", "data:application/json," + encodeURIComponent(JSON.stringify(data)));
el.setAttribute("download", filename);
el.click();
});
}
function import_data(e) {
if (requests.value.length > 0) {
// don't replace existing capture
return;
}
const request_file = e.dataTransfer.files[0];
const file_reader = new FileReader();
file_reader.readAsText(request_file, "UTF-8");
file_reader.onload = ({target: {result}}) => {
requests.value = JSON.parse(result);
}
}
// Mounted
onMounted(() => {
fetch_status();
refresh();
page.set_secondary_action(__("Clear"), () => {
frappe.set_route("recorder");
clear();
});
page.add_menu_item("Export data", () => export_data());
});
// Computed
let pages = computed(() => {
const current_page = query.value.pagination.page;
const total_pages = query.value.pagination.total;
return [{
label: __("First"),
number: 1,
status: (current_page == 1) ? "disabled" : "",
},{
label: __("Previous"),
number: Math.max(current_page - 1, 1),
status: (current_page == 1) ? "disabled" : "",
}, {
label: current_page,
number: current_page,
status: "btn-info",
}, {
label: __("Next"),
number: Math.min(current_page + 1, total_pages),
status: (current_page == total_pages) ? "disabled" : "",
}, {
label: __("Last"),
number: total_pages,
status: (current_page == total_pages) ? "disabled" : "",
}];
});
</script>
<style scoped>
.list-row .level-left {
flex: 8;
width: 100%;
}
</style>

View file

@ -1,17 +1,20 @@
<template>
<keep-alive include="RecorderDetail">
<router-view/>
</keep-alive>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component"></component>
</keep-alive>
</router-view>
</template>
<script>
export default {
name: "RecorderRoot",
watch: {
$route() {
frappe.router.current_route = frappe.router.parse();
frappe.breadcrumbs.update();
}
}
};
<script setup>
import { watch } from "vue"
import { useRoute } from "vue-router"
let route = useRoute();
watch(route, () => {
frappe.router.current_route = frappe.router.parse();
frappe.breadcrumbs.update();
frappe.recorder.route = route;
});
</script>

View file

@ -53,7 +53,7 @@
<div class="static-area ellipsis">{{ __("Query") }}</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">{{ __("Duration (ms)") }}</div>
<div class="static-area ellipsis text-right">{{ __("Duration (ms)") }}"</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">{{ __("Exact Copies") }}</div>
@ -101,7 +101,7 @@
</div>
<div class="frappe-control input-max-width">
<div class="form-group">
<div class="clearfix"><label class="control-label">{{ __("Duration (ms)") }}</label></div>
<div class="clearfix"><label class="control-label">{{ __("Duration (ms)") }}"</label></div>
<div class="control-value like-disabled-input">{{ call.duration }}</div>
</div>
</div>
@ -122,11 +122,9 @@
</tr>
</thead>
<tbody>
<template v-for="(row, index) in call.stack">
<tr :key="index">
<td v-for="key in ['filename', 'lineno', 'function']" :key="key">{{ row[key] }}</td>
</tr>
</template>
<tr v-for="(row, index) in call.stack" :key="index">
<td v-for="key in ['filename', 'lineno', 'function']" :key="key">{{ row[key] }}</td>
</tr>
</tbody>
</table>
</div>
@ -192,112 +190,113 @@
</div>
</template>
<script>
export default {
name: "RequestDetail",
data() {
return {
columns: [
{label: __("Path"), slug: "path", type: "Data", class: "col-sm-6"},
{label: __("CMD"), slug: "cmd", type: "Data", class: "col-sm-6"},
{label: __("Time"), slug: "time", type: "Time", class: "col-sm-6"},
{label: __("Duration (ms)"), slug: "duration", type: "Float", class: "col-sm-6"},
{label: __("Number of Queries"), slug: "queries", type: "Int", class: "col-sm-6"},
{label: __("Time in Queries (ms)"), slug: "time_queries", type: "Float", class: "col-sm-6"},
{label: __("Request Headers"), slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: __("Form Dict"), slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
],
table_columns: [
{label: __("Execution Order"), slug: "index", sortable: true},
{label: __("Duration (ms)"), slug: "duration", sortable: true},
{label: __("Exact Copies"), slug: "exact_copies", sortable: true},
],
query: {
sort: "duration",
order: "desc",
pagination: {
limit: 20,
page: 1,
total: 0,
}
},
group_duplicates: false,
showing: null,
request: {
calls: [],
},
};
},
computed: {
pages: function() {
const current_page = this.query.pagination.page;
const total_pages = this.query.pagination.total;
return [{
label: __("First"),
number: 1,
status: (current_page == 1) ? "disabled" : "",
},{
label: __("Previous"),
number: Math.max(current_page - 1, 1),
status: (current_page == 1) ? "disabled" : "",
}, {
label: current_page,
number: current_page,
status: "btn-info",
}, {
label: __("Next"),
number: Math.min(current_page + 1, total_pages),
status: (current_page == total_pages) ? "disabled" : "",
}, {
label: __("Last"),
number: total_pages,
status: (current_page == total_pages) ? "disabled" : "",
}];
}
},
methods: {
paginated: function(calls) {
calls = calls.slice();
this.query.pagination.total = Math.ceil(calls.length / this.query.pagination.limit);
const begin = (this.query.pagination.page - 1) * (this.query.pagination.limit);
const end = begin + this.query.pagination.limit;
return calls.slice(begin, end);
},
sorted: function(calls) {
calls = calls.slice();
const order = (this.query.order == "asc") ? 1 : -1;
const sort = this.query.sort;
return calls.sort((a,b) => (a[sort] > b[sort]) ? order : -order);
},
grouped: function(calls) {
if(this.group_duplicates) {
calls = calls.slice();
return calls.uniqBy(call => call["query"]);
}
return calls
},
},
mounted() {
frappe.breadcrumbs.add({
type: 'Custom',
label: __('Recorder'),
route: '/app/recorder'
});
<script setup>
import { computed, onMounted, ref } from "vue"
import { useRoute } from "vue-router"
// variables
let route = ref(useRoute());
let columns = [
{label: __("Path"), slug: "path", type: "Data", class: "col-sm-6"},
{label: __("CMD"), slug: "cmd", type: "Data", class: "col-sm-6"},
{label: __("Time"), slug: "time", type: "Time", class: "col-sm-6"},
{label: __("Duration (ms)"), slug: "duration", type: "Float", class: "col-sm-6"},
{label: __("Number of Queries"), slug: "queries", type: "Int", class: "col-sm-6"},
{label: __("Time in Queries (ms)"), slug: "time_queries", type: "Float", class: "col-sm-6"},
{label: __("Request Headers"), slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: __("Form Dict"), slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
];
let table_columns = [
{label: __("Execution Order"), slug: "index", sortable: true},
{label: __("Duration (ms)"), slug: "duration", sortable: true},
{label: __("Exact Copies"), slug: "exact_copies", sortable: true},
];
let query = ref({
sort: "duration",
order: "desc",
pagination: {
limit: 20,
page: 1,
total: 0,
}
});
let group_duplicates = ref(false);
let showing = ref(null);
let request = ref({
calls: [],
});
// Methods
function paginated(calls) {
calls = calls.slice();
query.value.pagination.total = Math.ceil(calls.length / query.value.pagination.limit);
const begin = (query.value.pagination.page - 1) * (query.value.pagination.limit);
const end = begin + query.value.pagination.limit;
return calls.slice(begin, end);
}
function sorted(calls) {
calls = calls.slice();
const order = (query.value.order == "asc") ? 1 : -1;
const sort = query.value.sort;
return calls.sort((a,b) => (a[sort] > b[sort]) ? order : -order);
}
function grouped(calls) {
if(group_duplicates.value) {
calls = calls.slice();
return calls.uniqBy(call => call["query"]);
}
return calls
}
// Computed
let pages = computed(() => {
const current_page = query.value.pagination.page;
const total_pages = query.value.pagination.total;
return [{
label: __("First"),
number: 1,
status: (current_page == 1) ? "disabled" : "",
},{
label: __("Previous"),
number: Math.max(current_page - 1, 1),
status: (current_page == 1) ? "disabled" : "",
}, {
label: current_page,
number: current_page,
status: "btn-info",
}, {
label: __("Next"),
number: Math.min(current_page + 1, total_pages),
status: (current_page == total_pages) ? "disabled" : "",
}, {
label: __("Last"),
number: total_pages,
status: (current_page == total_pages) ? "disabled" : "",
}];
});
// Mounted
onMounted(async () => {
frappe.breadcrumbs.add({
type: "Custom",
label: __("Recorder"),
route: "/app/recorder"
});
const req = route.value.meta.request;
const id = route.value.params.id;
if (req && (req.headers || req.form_dict || req.calls)) {
// complete request data passed as parameter.
request.value = req;
} else {
let r = await frappe.call({
method: "frappe.recorder.get",
args: {
uuid: req?.uuid || id,
}
});
request.value = r.message;
}
});
const request = this.$route.params.request;
if (request.headers || request.form_dict || request.calls) {
// complete request data passed as parameter.
this.request = request;
} else {
frappe.call({
method: "frappe.recorder.get",
args: {
uuid: request.uuid
}
}).then( r => {
this.request = r.message
});
}
},
};
</script>

View file

@ -0,0 +1,8 @@
import { createApp } from "vue";
import RecorderRoot from "./RecorderRoot.vue";
import router from "./router.js";
let app = createApp(RecorderRoot).use(router);
SetVueGlobals(app);
app.mount(".recorder-container");
frappe.recorder.view = app;

View file

@ -1,48 +0,0 @@
import Vue from "vue/dist/vue.js";
import VueRouter from "vue-router/dist/vue-router.js";
import RecorderRoot from "./RecorderRoot.vue";
import RecorderDetail from "./RecorderDetail.vue";
import RequestDetail from "./RequestDetail.vue";
Vue.prototype.__ = window.__;
Vue.prototype.frappe = window.frappe;
Vue.use(VueRouter);
const routes = [
{
name: "recorder-detail",
path: "/detail",
component: RecorderDetail,
},
{
name: "request-detail",
path: "/request/:id",
component: RequestDetail,
},
{
path: "/",
redirect: {
name: "recorder-detail",
},
},
];
const router = new VueRouter({
mode: "history",
base: "/app/recorder/",
routes: routes,
});
frappe.recorder.view = new Vue({
el: ".recorder-container",
router: router,
data: {
page: frappe.pages["recorder"].page,
},
template: "<recorder-root/>",
components: {
RecorderRoot,
},
});

View file

@ -0,0 +1,28 @@
import { createWebHistory, createRouter } from "vue-router";
import RecorderDetail from "./RecorderDetail.vue";
import RequestDetail from "./RequestDetail.vue";
const routes = [
{
path: "/detail",
name: "RecorderDetail",
component: RecorderDetail,
},
{
path: "/request/:id",
name: "RequestDetail",
component: RequestDetail,
meta: { shouldFetch: true },
},
{
path: "/",
redirect: "/detail",
},
];
const router = createRouter({
history: createWebHistory("/app/recorder/"),
routes,
});
export default router;

View file

@ -190,12 +190,8 @@ frappe.router = {
} else {
route = ["List", doctype_route.doctype, "List"];
}
if (doctype_route.doctype_layout) {
// set the layout
this.doctype_layout = doctype_route.doctype_layout;
}
// reset the layout to avoid using incorrect views
this.doctype_layout = doctype_route.doctype_layout;
return route;
},

View file

@ -42,16 +42,13 @@ frappe.socketio = {
data.percent = (flt(data.progress[0]) / data.progress[1]) * 100;
}
if (data.percent) {
if (data.percent == 100) {
frappe.hide_progress();
} else {
frappe.show_progress(
data.title || __("Progress"),
data.percent,
100,
data.description
);
}
frappe.show_progress(
data.title || __("Progress"),
data.percent,
100,
data.description,
true
);
}
});

View file

@ -169,6 +169,9 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
}
set_df_property(fieldname, prop, value) {
if (!fieldname) {
return;
}
const field = this.get_field(fieldname);
field.df[prop] = value;
field.refresh();

View file

@ -327,12 +327,12 @@ class NotificationsView extends BaseNotificationsView {
}
get_item_link(notification_doc) {
const link_doctype =
notification_doc.type == "Alert" ? "Notification Log" : notification_doc.document_type;
const link_docname =
notification_doc.type == "Alert"
? notification_doc.name
: notification_doc.document_name;
const link_doctype = notification_doc.document_type
? notification_doc.document_type
: "Notification Log";
const link_docname = notification_doc.document_name
? notification_doc.document_name
: notification_doc.name;
return frappe.utils.get_form_link(link_doctype, link_docname);
}

View file

@ -0,0 +1,80 @@
$(document).on("startup", async () => {
if (!frappe.boot.setup_complete || !frappe.user.has_role("System Manager")) {
return;
}
const expiry = frappe.boot.subscription_expiry;
if (expiry) {
let diff_days =
frappe.datetime.get_day_diff(cstr(expiry), frappe.datetime.get_today()) - 1;
let subscription_string = __(
`Your subscription will end in ${cstr(diff_days).bold()} ${
diff_days > 1 ? "days" : "day"
}. After that your site will be suspended.`
);
let $bar = $(`
<div
class="position-fixed top-100 start-20 translate-middle shadow sm:rounded-lg py-2"
style="left: 10%; bottom:20px; width:80%; margin: auto; text-align: center; border-radius: 10px; background-color: rgb(240 249 255); z-index: 1"
>
<div
style="display: flex; align-items: center; justify-content: space-between; text-align: center;"
class="text-muted"
>
<p style="float: left; margin: auto; font-size: 17px">${subscription_string}</p>
<div style="display: flex; align-items: center; justify-content: space-between">
<button
type="button"
class="button-renew px-4 py-2 border border-transparent text-white hover:bg-indigo-700 focus:outline-none focus:ring-offset-2 focus:ring-indigo-500"
style="background-color: #0089FF; border-radius: 5px; margin-right: 10px; height: fit-content;"
>
Subscribe
</button>
<a
type="button"
class="dismiss-upgrade text-muted" data-dismiss="modal" aria-hidden="true" style="font-size:30px; margin-bottom: 5px; margin-right: 10px"
>
\u00d7
</a>
</div>
</div>
</div>
`);
$("footer").append($bar);
$bar.find(".dismiss-upgrade").on("click", () => {
$bar.remove();
});
$bar.find(".button-renew").on("click", () => {
redirectToUrl();
});
}
});
function redirectToUrl() {
frappe.call({
method: "frappe.utils.subscription.remote_login",
callback: (url) => {
if (url.message !== false) {
window.open(url.message, "_blank");
} else {
frappe.msgprint({
title: __("Message"),
indicator: "orange",
message: __("No active subscriptions found."),
});
}
},
});
}
$.extend(frappe.ui.toolbar, {
redirectToUrl() {
redirectToUrl();
},
});

View file

@ -1,7 +1,10 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt
import FileUploader from "./file_uploader";
frappe.provide("frappe.ui");
frappe.ui.FileUploader = FileUploader;
if (frappe.require) {
frappe.require("file_uploader.bundle.js");
} else {
frappe.ready(function () {
frappe.require("file_uploader.bundle.js");
});
}

View file

@ -211,17 +211,21 @@ $.extend(frappe.datetime, {
return frappe.datetime._date(frappe.defaultDatetimeFormat, as_obj);
},
_date: function (format, as_obj = false) {
/**
* Whenever we are getting now_date/datetime, always make sure dates are fetched using user time zone.
* This is to make sure that time is as per user time zone set in User doctype, If a user had to change the timezone,
* we will end up having multiple timezone by not honouring timezone in User doctype.
* This will make sure that at any point we know which timezone the user if following and not have random timezone
* when the timezone of the local machine changes.
*/
let time_zone = frappe.boot.time_zone
? frappe.boot.time_zone.user || frappe.boot.time_zone.system
: frappe.sys_defaults.time_zone;
system_datetime: function (as_obj = false) {
return frappe.datetime._date(frappe.defaultDatetimeFormat, as_obj, true);
},
_date: function (format, as_obj = false, system_time = false) {
let time_zone = frappe.boot.time_zone?.system || frappe.sys_defaults.time_zone;
// Whenever we are getting now_date/datetime, always make sure dates are fetched using user time zone.
// This is to make sure that time is as per user time zone set in User doctype, If a user had to change the timezone,
// we will end up having multiple timezone by not honouring timezone in User doctype.
// This will make sure that at any point we know which timezone the user if following and not have random timezone
// when the timezone of the local machine changes.
if (!system_time) {
time_zone = frappe.boot.time_zone?.user || time_zone;
}
let date = moment.tz(time_zone);
return as_obj ? frappe.datetime.moment_to_date_obj(date) : date.format(format);

View file

@ -11,7 +11,9 @@ function prettyDate(date, mini) {
);
}
let diff = (new Date(frappe.datetime.now_datetime()).getTime() - date.getTime()) / 1000;
let diff =
(new Date(frappe.datetime.now_datetime().replace(/-/g, "/")).getTime() - date.getTime()) /
1000;
let day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0) return "";

View file

@ -3,6 +3,7 @@
import deep_equal from "fast-deep-equal";
import number_systems from "./number_systems";
import cloneDeepWith from "lodash/cloneDeepWith";
frappe.provide("frappe.utils");
@ -1000,6 +1001,10 @@ Object.assign(frappe.utils, {
return deep_equal(a, b);
},
deep_clone(obj, customizer) {
return cloneDeepWith(obj, customizer);
},
file_name_ellipsis(filename, length) {
let first_part_length = (length * 2) / 3;
let last_part_length = length - first_part_length;

View file

@ -1,6 +1,6 @@
// TODO: Refactor for better UX
import Vuex from "vuex";
import { createStore } from "vuex";
frappe.provide("frappe.views");
@ -9,7 +9,7 @@ frappe.provide("frappe.views");
let columns_unwatcher = null;
var store = new Vuex.Store({
var store = createStore({
state: {
doctype: "",
board: {},

View file

@ -1,23 +0,0 @@
import Modules from "./components/Modules.vue";
frappe.provide("frappe.modules");
frappe.modules.Home = class {
constructor({ parent }) {
this.$parent = $(parent);
this.page = parent.page;
this.setup_header();
this.make_body();
}
make_body() {
this.$modules_container = this.$parent.find(".layout-main");
new Vue({
el: this.$modules_container[0],
render: (h) => h(Modules),
});
}
setup_header() {
// subtitle
}
};

View file

@ -571,12 +571,13 @@ export default class ChartWidget extends Widget {
Heatmap: "heatmap",
};
let max_slices = ["Pie", "Donut"].includes(this.chart_doc.type) ? 6 : 9;
let chart_args = {
data: this.data,
type: chart_type_map[this.chart_doc.type],
colors: colors,
height: this.height,
maxSlices: ["Pie", "Donut"].includes(this.chart_doc.type) ? 6 : 9,
maxSlices: this.chart_doc.number_of_groups || max_slices,
axisOptions: {
xIsSeries: this.chart_doc.timeseries,
shortenYAxisNumbers: 1,

View file

@ -1,7 +1,9 @@
import "./jquery-bootstrap";
import Vue from "vue/dist/vue.esm.js";
import "./lib/moment";
import Sortable from "sortablejs";
window.Vue = Vue;
window.SetVueGlobals = (app) => {
app.config.globalProperties.__ = window.__;
app.config.globalProperties.frappe = window.frappe;
};
window.Sortable = Sortable;

View file

@ -18,78 +18,74 @@
:group="df.fieldname"
handle=".icon-drag"
>
<div
class="mt-2 row align-center column-row"
v-for="column in df.table_columns"
>
<div class="col-8">
<div class="column-label d-flex align-center">
<div class="px-2 icon-drag ml-n2">
<svg class="icon icon-xs">
<use href="#icon-drag"></use>
</svg>
</div>
<div class="mt-1 ml-1">
<input
class="input-column-label"
:class="{ 'text-danger': column.invalid_width }"
type="text"
v-model="column.label"
/>
<template #item="{ element }">
<div
class="mt-2 row align-center column-row"
v-for="column in df.table_columns"
>
<div class="col-8">
<div class="column-label d-flex align-center">
<div class="px-2 icon-drag ml-n2">
<svg class="icon icon-xs">
<use href="#icon-drag"></use>
</svg>
</div>
<div class="mt-1 ml-1">
<input
class="input-column-label"
:class="{ 'text-danger': column.invalid_width }"
type="text"
v-model="column.label"
/>
</div>
</div>
</div>
<div class="col-4 d-flex align-items-center">
<input
type="number"
class="text-right form-control"
:class="{ 'text-danger is-invalid': column.invalid_width }"
v-model.number="column.width"
min="0"
max="100"
step="5"
/>
<button
class="ml-2 btn btn-xs btn-icon"
@click="remove_column(column)"
>
<svg class="icon icon-sm">
<use href="#icon-close"></use>
</svg>
</button>
</div>
</div>
<div class="col-4 d-flex align-items-center">
<input
type="number"
class="text-right form-control"
:class="{ 'text-danger is-invalid': column.invalid_width }"
v-model.number="column.width"
min="0"
max="100"
step="5"
/>
<button
class="ml-2 btn btn-xs btn-icon"
@click="remove_column(column)"
>
<svg class="icon icon-sm">
<use href="#icon-close"></use>
</svg>
</button>
</div>
</div>
</template>
</draggable>
</div>
</template>
<script>
<script setup>
import { computed } from "vue";
import draggable from "vuedraggable";
export default {
name: "ConfigureColumns",
props: ["df"],
components: {
draggable
},
methods: {
remove_column(column) {
this.$set(
this.df,
"table_columns",
this.df.table_columns.filter(_column => _column !== column)
);
}
},
computed: {
help_message() {
// prettier-ignore
return __("Drag columns to set order. Column width is set in percentage. The total width should not be more than 100. Columns marked in red will be removed.");
},
total_width() {
return this.df.table_columns.reduce((total, tf) => total + tf.width, 0);
}
}
};
// props
let props = defineProps(["df"]);
// methods
function remove_column(column) {
props.df["table_columns"] = props.df.table_columns.filter(_column => _column !== column)
}
// computed
let help_message = computed(() => {
// prettier-ignore
return __("Drag columns to set order. Column width is set in percentage. The total width should not be more than 100. Columns marked in red will be removed.");
});
let total_width = computed(() => {
return props.df.table_columns.reduce((total, tf) => total + tf.width, 0);
});
</script>
<style scoped>
.icon-drag {
cursor: grab;

View file

@ -1,5 +1,5 @@
<template>
<div class="field" :title="df.fieldname" @click="editing = true">
<div class="field" v-show="!df.remove" :title="df.fieldname" @click="editing = true">
<div class="field-controls">
<div>
<div
@ -15,7 +15,7 @@
</div>
<input
v-else-if="editing && df.fieldtype != 'HTML'"
ref="label-input"
ref="label_input"
class="label-input"
type="text"
:placeholder="__('Label')"
@ -47,7 +47,7 @@
</button>
<button
class="btn btn-xs btn-icon"
@click="$set(df, 'remove', true)"
@click="df['remove'] = true"
>
<svg class="icon icon-sm">
<use href="#icon-close"></use>
@ -73,171 +73,151 @@
</div>
</div>
</template>
<script>
import draggable from "vuedraggable";
import ConfigureColumnsVue from "./ConfigureColumns.vue";
import { storeMixin } from "./store";
export default {
name: "Field",
mixins: [storeMixin],
props: ["df"],
components: {
draggable
},
data() {
return {
editing: false
};
},
watch: {
editing(value) {
if (value) {
this.$nextTick(() => this.$refs["label-input"].focus());
}
},
"df.table_columns": {
deep: true,
handler() {
this.validate_table_columns();
<script setup>
import ConfigureColumnsVue from "./ConfigureColumns.vue";
import { createApp, ref, nextTick, watch } from "vue";
// props
let props = defineProps(["df"]);
// variables
let editing = ref(false);
let label_input = ref(null);
// methods
function edit_html() {
let d = new frappe.ui.Dialog({
title: __("Edit HTML"),
fields: [
{
label: __("HTML"),
fieldname: "html",
fieldtype: "Code",
options: "HTML"
}
],
primary_action: ({ html }) => {
html = frappe.dom.remove_script_and_style(html);
props.df["html"] = html;
d.hide();
}
},
methods: {
edit_html() {
let d = new frappe.ui.Dialog({
title: __("Edit HTML"),
fields: [
{
label: __("HTML"),
fieldname: "html",
fieldtype: "Code",
options: "HTML"
}
],
primary_action: ({ html }) => {
html = frappe.dom.remove_script_and_style(html);
this.$set(this.df, "html", html);
d.hide();
}
});
d.set_value("html", this.df.html);
d.show();
},
configure_columns() {
let dialog = new frappe.ui.Dialog({
title: __("Configure columns for {0}", [this.df.label]),
fields: [
{
fieldtype: "HTML",
fieldname: "columns_area"
},
{
label: "",
fieldtype: "Autocomplete",
placeholder: __("Add Column"),
fieldname: "add_column",
options: this.get_all_columns(),
onchange: () => {
let fieldname = dialog.get_value("add_column");
if (fieldname) {
let column = this.get_column_to_add(fieldname);
if (column) {
this.df.table_columns.push(column);
this.$set(
this.df,
"table_columns",
this.df.table_columns
);
dialog.set_value("add_column", "");
}
}
});
d.set_value("html", props.df.html);
d.show();
}
function configure_columns() {
let dialog = new frappe.ui.Dialog({
title: __("Configure columns for {0}", [props.df.label]),
fields: [
{
fieldtype: "HTML",
fieldname: "columns_area"
},
{
label: "",
fieldtype: "Autocomplete",
placeholder: __("Add Column"),
fieldname: "add_column",
options: get_all_columns(),
onchange: () => {
let fieldname = dialog.get_value("add_column");
if (fieldname) {
let column = get_column_to_add(fieldname);
if (column) {
props.df.table_columns.push(column);
props.df["table_columns"] = props.df.table_columns;
dialog.set_value("add_column", "");
}
}
],
on_page_show: () => {
new Vue({
el: dialog.get_field("columns_area").$wrapper.get(0),
render: h =>
h(ConfigureColumnsVue, {
props: {
df: this.df
}
})
});
},
on_hide: () => {
this.$set(
this.df,
"table_columns",
this.df.table_columns.filter(col => !col.invalid_width)
);
}
});
dialog.show();
},
get_all_columns() {
let meta = frappe.get_meta(this.df.options);
let more_columns = [
{
label: __("Sr No."),
value: "idx"
}
];
return more_columns.concat(
meta.fields
.map(tf => {
if (frappe.model.no_value_type.includes(tf.fieldtype)) {
return;
}
return {
label: tf.label,
value: tf.fieldname
};
})
.filter(Boolean)
}
],
on_page_show: () => {
createApp(ConfigureColumnsVue, { df: props.df }).mount(
dialog.get_field("columns_area").$wrapper.get(0)
);
},
get_column_to_add(fieldname) {
let standard_columns = {
idx: {
label: __("Sr No."),
fieldtype: "Data",
fieldname: "idx",
width: 10
on_hide: () => {
props.df["table_columns"] = props.df.table_columns.filter(col => !col.invalid_width);
}
});
dialog.show();
}
function get_all_columns() {
let meta = frappe.get_meta(props.df.options);
let more_columns = [
{
label: __("Sr No."),
value: "idx"
}
];
return more_columns.concat(
meta.fields
.map(tf => {
if (frappe.model.no_value_type.includes(tf.fieldtype)) {
return;
}
};
return {
label: tf.label,
value: tf.fieldname
};
})
.filter(Boolean)
);
}
function get_column_to_add(fieldname) {
let standard_columns = {
idx: {
label: __("Sr No."),
fieldtype: "Data",
fieldname: "idx",
width: 10
}
};
if (fieldname in standard_columns) {
return standard_columns[fieldname];
}
if (fieldname in standard_columns) {
return standard_columns[fieldname];
}
return {
...frappe.meta.get_docfield(this.df.options, fieldname),
width: 10
};
},
validate_table_columns() {
if (this.df.fieldtype != "Table") return;
return {
...frappe.meta.get_docfield(props.df.options, fieldname),
width: 10
};
}
function validate_table_columns() {
if (props.df.fieldtype != "Table") return;
let columns = this.df.table_columns;
let total_width = 0;
for (let column of columns) {
if (!column.width) {
column.width = 10;
}
total_width += column.width;
if (total_width > 100) {
column.invalid_width = true;
} else {
column.invalid_width = false;
}
}
let columns = props.df.table_columns;
let total_width = 0;
for (let column of columns) {
if (!column.width) {
column.width = 10;
}
total_width += column.width;
if (total_width > 100) {
column.invalid_width = true;
} else {
column.invalid_width = false;
}
}
};
}
// watch
watch(editing, (value) => {
if (value) {
nextTick(() => label_input.value.focus());
}
});
watch(
() => props.df.table_columns,
() => validate_table_columns(),
{ deep: true }
);
</script>
<style>
<style scoped>
.field {
text-align: left;
width: 100%;

View file

@ -12,48 +12,53 @@
<div v-show="editing" ref="editor"></div>
</div>
</template>
<script>
export default {
name: "HTMLEditor",
props: ["value", "button-label"],
data() {
return {
editing: false
};
},
methods: {
toggle_edit() {
if (this.editing) {
this.$emit("change", this.get_value());
this.editing = false;
return;
}
this.editing = true;
if (!this.control) {
this.control = frappe.ui.form.make_control({
parent: this.$refs.editor,
df: {
fieldname: "editor",
fieldtype: "HTML Editor",
min_lines: 10,
max_lines: 30,
change: () => {
this.$emit("change", this.get_value());
}
},
render_input: true
});
}
this.control.set_value(this.value);
},
get_value() {
return frappe.dom.remove_script_and_style(this.control.get_value());
}
<script setup>
import { ref } from "vue";
// props
let props = defineProps(["value", "button-label"]);
// emits
let emit = defineEmits(["change"]);
// variables
let editing = ref(false);
let control = ref(null);
let editor = ref(null);
// methods
function toggle_edit() {
if (editing.value) {
emit("change", get_value());
editing.value = false;
return;
}
};
editing.value = true;
if (!control.value) {
control.value = frappe.ui.form.make_control({
parent: editor.value,
df: {
fieldname: "editor",
fieldtype: "HTML Editor",
min_lines: 10,
max_lines: 30,
change: () => {
emit("change", get_value());
}
},
render_input: true
});
}
control.value.set_value(props.value);
}
function get_value() {
return frappe.dom.remove_script_and_style(control.value.get_value());
}
</script>
<style>
<style scoped>
.html-editor {
position: relative;
border: 1px solid var(--dark-border-color);

View file

@ -3,7 +3,7 @@
<div class="mb-4 d-flex justify-content-between">
<div class="d-flex align-items-center">
<div
v-if="letterhead && $store.edit_letterhead"
v-if="letterhead && store.edit_letterhead"
class="btn-group"
role="group"
aria-label="Align Letterhead"
@ -24,7 +24,7 @@
</div>
<input
class="ml-4 custom-range"
v-if="letterhead && $store.edit_letterhead"
v-if="letterhead && store.edit_letterhead"
type="range"
name="image-resize"
min="20"
@ -41,13 +41,13 @@
<div>
<button
class="ml-2 btn btn-default btn-xs"
v-if="letterhead && $store.edit_letterhead"
v-if="letterhead && store.edit_letterhead"
@click="upload_image"
>
{{ __("Change Image") }}
</button>
<button
v-if="letterhead && $store.edit_letterhead"
v-if="letterhead && store.edit_letterhead"
class="ml-2 btn btn-default btn-xs btn-change-letterhead"
@click="change_letterhead"
>
@ -59,7 +59,7 @@
@click="toggle_edit_letterhead"
>
{{
!$store.edit_letterhead
!store.edit_letterhead
? __("Edit Letter Head")
: __("Done")
}}
@ -74,13 +74,13 @@
</div>
</div>
<div
v-if="letterhead && !$store.edit_letterhead"
v-if="letterhead && !store.edit_letterhead"
v-html="letterhead.content"
></div>
<!-- <div v-show="letterhead && $store.edit_letterhead" ref="editor"></div> -->
<!-- <div v-show="letterhead && store.edit_letterhead" ref="editor"></div> -->
<div
class="edit-letterhead"
v-if="letterhead && $store.edit_letterhead"
v-if="letterhead && store.edit_letterhead"
:style="{
justifyContent: {
Left: 'flex-start',
@ -112,199 +112,183 @@
</div>
</div>
</template>
<script>
import { storeMixin } from "./store";
<script setup>
import { useStore } from "./store";
import { get_image_dimensions } from "./utils";
export default {
name: "LetterHeadEditor",
mixins: [storeMixin],
data() {
return {
range_input_field: null,
aspect_ratio: null
};
},
watch: {
letterhead: {
deep: true,
immediate: true,
handler(letterhead) {
if (!letterhead) return;
if (letterhead.image_width && letterhead.image_height) {
let dimension =
letterhead.image_width > letterhead.image_height
? "width"
: "height";
let dimension_value = letterhead["image_" + dimension];
letterhead.content = `
<div style="text-align: ${letterhead.align.toLowerCase()};">
<img
src="${letterhead.image}"
alt="${letterhead.name}"
${dimension}="${dimension_value}"
style="${dimension}: ${dimension_value}px;">
</div>
`;
import { ref, watch, onMounted } from "vue";
// mixin
let { letterhead, store } = useStore();
// variables
let range_input_field = ref(null);
let aspect_ratio = ref(null);
let control = ref(null);
let editor = ref(null);
// methods
function toggle_edit_letterhead() {
if (store.value.edit_letterhead) {
store.value.edit_letterhead = false;
return;
}
store.value.edit_letterhead = true;
if (!control.value) {
control.value = frappe.ui.form.make_control({
parent: editor.value,
df: {
fieldname: "letterhead",
fieldtype: "Comment",
change: () => {
letterhead.value._dirty = true;
letterhead.value.content = control.value.get_value();
}
}
}
},
mounted() {
if (!this.letterhead && frappe.boot.sysdefaults.letter_head) {
this.set_letterhead(frappe.boot.sysdefaults.letter_head);
}
this.$watch(
function() {
return this.letterhead
? this.letterhead[this.range_input_field]
: null;
},
function() {
if (this.aspect_ratio === null) return;
render_input: true,
only_input: true,
no_wrapper: true
});
}
control.value.set_value(letterhead.value.content);
};
function change_letterhead() {
let d = new frappe.ui.Dialog({
title: __("Change Letter Head"),
fields: [
{
label: __("Letter Head"),
fieldname: "letterhead",
fieldtype: "Link",
options: "Letter Head"
}
],
primary_action: ({ letterhead }) => {
if (letterhead) {
set_letterhead(letterhead);
}
d.hide();
}
});
d.show();
};
function upload_image() {
new frappe.ui.FileUploader({
folder: "Home/Attachments",
on_success: file_doc => {
get_image_dimensions(file_doc.file_url).then(
({ width, height }) => {
letterhead.value["image"] = file_doc.file_url;
let new_width = width;
let new_height = height;
aspect_ratio.value = width / height;
range_input_field.value =
aspect_ratio.value > 1
? "image_width"
: "image_height";
let update_field =
this.range_input_field == "image_width"
? "image_height"
: "image_width";
this.letterhead[update_field] =
update_field == "image_width"
? this.aspect_ratio * this.letterhead.image_height
: this.letterhead.image_width / this.aspect_ratio;
if (width > 200) {
new_width = 200;
new_height = new_width / aspect_ratio.value;
}
if (height > 80) {
new_height = 80;
new_width = aspect_ratio.value * new_height;
}
letterhead.value["image_height"] = new_height;
letterhead.value["image_width"] = new_width;
}
);
}
});
};
function set_letterhead(_letterhead) {
store.value.change_letterhead(_letterhead).then(() => {
get_image_dimensions(letterhead.value.image).then(
({ width, height }) => {
aspect_ratio.value = width / height;
range_input_field.value =
aspect_ratio.value > 1
? "image_width"
: "image_height";
}
);
},
methods: {
toggle_edit_letterhead() {
if (this.$store.edit_letterhead) {
this.$store.edit_letterhead = false;
return;
}
this.$store.edit_letterhead = true;
if (!this.control) {
this.control = frappe.ui.form.make_control({
parent: this.$refs.editor,
df: {
fieldname: "letterhead",
fieldtype: "Comment",
change: () => {
this.letterhead._dirty = true;
this.letterhead.content = this.control.get_value();
}
},
render_input: true,
only_input: true,
no_wrapper: true
});
}
this.control.set_value(this.letterhead.content);
},
change_letterhead() {
let d = new frappe.ui.Dialog({
title: __("Change Letter Head"),
fields: [
{
label: __("Letter Head"),
fieldname: "letterhead",
fieldtype: "Link",
options: "Letter Head"
}
],
primary_action: ({ letterhead }) => {
if (letterhead) {
this.set_letterhead(letterhead);
}
d.hide();
}
});
d.show();
},
upload_image() {
new frappe.ui.FileUploader({
folder: "Home/Attachments",
on_success: file_doc => {
get_image_dimensions(file_doc.file_url).then(
({ width, height }) => {
this.$set(
this.letterhead,
"image",
file_doc.file_url
);
let new_width = width;
let new_height = height;
this.aspect_ratio = width / height;
this.range_input_field =
this.aspect_ratio > 1
? "image_width"
: "image_height";
if (width > 200) {
new_width = 200;
new_height = new_width / aspect_ratio;
}
if (height > 80) {
new_height = 80;
new_width = aspect_ratio * new_height;
}
this.$set(
this.letterhead,
"image_height",
new_height
);
this.$set(
this.letterhead,
"image_width",
new_width
);
}
);
}
});
},
set_letterhead(letterhead) {
this.$store.change_letterhead(letterhead).then(() => {
get_image_dimensions(this.letterhead.image).then(
({ width, height }) => {
this.aspect_ratio = width / height;
this.range_input_field =
this.aspect_ratio > 1
? "image_width"
: "image_height";
}
);
});
},
create_letterhead() {
let d = new frappe.ui.Dialog({
title: __("Create Letter Head"),
fields: [
{
label: __("Letter Head Name"),
fieldname: "name",
fieldtype: "Data"
}
],
primary_action: ({ name }) => {
return frappe.db
.insert({
doctype: "Letter Head",
letter_head_name: name,
source: "Image"
})
.then(doc => {
d.hide();
this.$store.change_letterhead(doc.name).then(() => {
this.toggle_edit_letterhead();
});
});
}
});
d.show();
}
}
});
};
function create_letterhead() {
let d = new frappe.ui.Dialog({
title: __("Create Letter Head"),
fields: [
{
label: __("Letter Head Name"),
fieldname: "name",
fieldtype: "Data"
}
],
primary_action: ({ name }) => {
return frappe.db
.insert({
doctype: "Letter Head",
letter_head_name: name,
source: "Image"
})
.then(doc => {
d.hide();
store.value.change_letterhead(doc.name).then(() => {
toggle_edit_letterhead();
});
});
}
});
d.show();
}
// mounted
onMounted(() => {
if (!letterhead.value && frappe.boot.sysdefaults.letter_head) {
set_letterhead(frappe.boot.sysdefaults.letter_head);
}
watch(() => {
return letterhead.value
? letterhead.value[range_input_field.value]
: null;
}, () => {
if (aspect_ratio.value === null) return;
let update_field =
range_input_field.value == "image_width"
? "image_height"
: "image_width";
letterhead.value[update_field] =
update_field == "image_width"
? aspect_ratio.value * letterhead.value.image_height
: letterhead.value.image_width / aspect_ratio.value;
});
});
// watch
watch(letterhead, () => {
if (!letterhead.value) return;
if (letterhead.value.image_width && letterhead.value.image_height) {
let dimension =
letterhead.value.image_width > letterhead.value.image_height
? "width"
: "height";
let dimension_value = letterhead.value["image_" + dimension];
letterhead.value.content = `
<div style="text-align: ${letterhead.value.align.toLowerCase()};">
<img
src="${letterhead.value.image}"
alt="${letterhead.value.name}"
${dimension}="${dimension_value}"
style="${dimension}: ${dimension_value}px;">
</div>
`;
}
}, { deep: true }, { immediate: true });
</script>
<style scoped>
.letterhead {
position: relative;

View file

@ -2,10 +2,10 @@
<div class="h-100">
<div class="row">
<div class="col">
<div class="preview-control" ref="doc-select"></div>
<div class="preview-control" ref="doc_select_ref"></div>
</div>
<div class="col">
<div class="preview-control" ref="preview-type"></div>
<div class="preview-control" ref="preview_type_ref"></div>
</div>
<div class="col d-flex">
<a
@ -36,85 +36,89 @@
></iframe>
</div>
</template>
<script>
import { storeMixin } from "./store";
export default {
name: "Preview",
mixins: [storeMixin],
data() {
return {
type: "PDF",
docname: null,
preview_loaded: false
};
},
mounted() {
this.doc_select = frappe.ui.form.make_control({
parent: this.$refs["doc-select"],
df: {
label: __("Select {0}", [__(this.doctype)]),
fieldname: "docname",
fieldtype: "Link",
options: this.doctype,
change: () => {
this.docname = this.doc_select.get_value();
}
},
render_input: true
});
this.preview_type = frappe.ui.form.make_control({
parent: this.$refs["preview-type"],
df: {
label: __("Preview type"),
fieldname: "docname",
fieldtype: "Select",
options: ["PDF", "HTML"],
change: () => {
this.type = this.preview_type.get_value();
}
},
render_input: true
});
this.preview_type.set_value(this.type);
this.get_default_docname().then(
docname => docname && this.doc_select.set_value(docname)
);
this.$store.$on("after_save", () => {
this.refresh();
});
},
methods: {
refresh() {
this.$refs.iframe.contentWindow.location.reload();
},
get_default_docname() {
return frappe.db.get_list(this.doctype, { limit: 1 }).then(doc => {
return doc.length > 0 ? doc[0].name : null;
});
}
},
computed: {
doctype() {
return this.print_format.doc_type;
},
url() {
if (!this.docname) return null;
let params = new URLSearchParams();
params.append("doctype", this.doctype);
params.append("name", this.docname);
params.append("print_format", this.print_format.name);
if (this.$store.letterhead) {
params.append("letterhead", this.$store.letterhead.name);
}
let url =
this.type == "PDF"
? `/api/method/frappe.utils.weasyprint.download_pdf`
: "/printpreview";
return `${url}?${params.toString()}`;
}
<script setup>
import { useStore } from "./store";
import { ref, computed, onMounted } from "vue";
// mixin
let { print_format, store } = useStore();
// variables
let type = ref("PDF");
let docname = ref(null);
let preview_loaded = ref(false);
let iframe = ref(null);
let doc_select_ref = ref(null);
let preview_type_ref = ref(null);
let doc_select = ref(null);
let preview_type = ref(null);
// methods
function refresh() {
iframe.value?.contentWindow.location.reload();
}
function get_default_docname() {
return frappe.db.get_list(doctype.value, { limit: 1 }).then(doc => {
return doc.length > 0 ? doc[0].name : null;
});
}
// computed
let doctype = computed(() => {
return print_format.value.doc_type;
});
let url = computed(() => {
if (!docname.value) return null;
let params = new URLSearchParams();
params.append("doctype", doctype.value);
params.append("name", docname.value);
params.append("print_format", print_format.value.name);
if (store.value.letterhead) {
params.append("letterhead", store.value.letterhead.name);
}
};
let _url =
type.value == "PDF"
? `/api/method/frappe.utils.weasyprint.download_pdf`
: "/printpreview";
return `${_url}?${params.toString()}`;
});
// mounted
onMounted(() => {
doc_select.value = frappe.ui.form.make_control({
parent: doc_select_ref.value,
df: {
label: __("Select {0}", [__(doctype.value)]),
fieldname: "docname",
fieldtype: "Link",
options: doctype.value,
change: () => {
docname.value = doc_select.value.get_value();
}
},
render_input: true
});
preview_type.value = frappe.ui.form.make_control({
parent: preview_type_ref.value,
df: {
label: __("Preview type"),
fieldname: "docname",
fieldtype: "Select",
options: ["PDF", "HTML"],
change: () => {
type.value = preview_type.value.get_value();
}
},
render_input: true
});
preview_type.value.set_value(type.value);
get_default_docname().then(doc_name => {
doc_name && doc_select.value.set_value(doc_name);
});
});
</script>
<style scoped>
.preview-iframe {
width: 100%;

View file

@ -5,7 +5,7 @@
<LetterHeadEditor type="Header" />
<HTMLEditor
:value="layout.header"
@change="$set(layout, 'header', $event)"
@change="layout.header = $event"
:button-label="__('Edit Header')"
/>
<draggable
@ -14,17 +14,18 @@
group="sections"
filter=".section-columns, .column, .field"
:animation="200"
item-key="id"
>
<PrintFormatSection
v-for="(section, i) in layout.sections"
:key="i"
:section="section"
@add_section_above="add_section_above(section)"
/>
<template #item="{ element }">
<PrintFormatSection
:section="element"
@add_section_above="add_section_above(element)"
/>
</template>
</draggable>
<HTMLEditor
:value="layout.footer"
@change="$set(layout, 'footer', $event)"
@change="layout.footer = $event"
:button-label="__('Edit Footer')"
/>
<HTMLEditor
@ -36,92 +37,87 @@
</div>
</template>
<script>
<script setup>
import draggable from "vuedraggable";
import HTMLEditor from "./HTMLEditor.vue";
import LetterHeadEditor from "./LetterHeadEditor.vue";
import PrintFormatSection from "./PrintFormatSection.vue";
import { storeMixin } from "./store";
import { useStore } from "./store";
import { computed, inject, watch } from "vue";
export default {
name: "PrintFormat",
mixins: [storeMixin],
components: {
draggable,
PrintFormatSection,
LetterHeadEditor,
HTMLEditor
},
computed: {
rootStyles() {
let {
margin_top = 0,
margin_bottom = 0,
margin_left = 0,
margin_right = 0
} = this.print_format;
return {
padding: `${margin_top}mm ${margin_right}mm ${margin_bottom}mm ${margin_left}mm`,
width: "210mm",
minHeight: "297mm"
};
},
page_number_style() {
let style = {
position: "absolute",
background: "white",
padding: "4px",
borderRadius: "var(--border-radius)",
border: "1px solid var(--border-color)"
};
if (this.print_format.page_number.includes("Top")) {
style.top = this.print_format.margin_top / 2 + "mm";
style.transform = "translateY(-50%)";
}
if (this.print_format.page_number.includes("Left")) {
style.left = this.print_format.margin_left + "mm";
}
if (this.print_format.page_number.includes("Right")) {
style.right = this.print_format.margin_right + "mm";
}
if (this.print_format.page_number.includes("Bottom")) {
style.bottom = this.print_format.margin_bottom / 2 + "mm";
style.transform = "translateY(50%)";
}
if (this.print_format.page_number.includes("Center")) {
style.left = "50%";
style.transform += " translateX(-50%)";
}
if (this.print_format.page_number.includes("Hide")) {
style.display = "none";
}
return style;
}
},
methods: {
add_section_above(section) {
let sections = [];
for (let _section of this.layout.sections) {
if (_section === section) {
sections.push({
label: "",
columns: [
{ label: "", fields: [] },
{ label: "", fields: [] }
]
});
}
sections.push(_section);
}
this.$set(this.layout, "sections", sections);
},
update_letterhead_footer(val) {
this.letterhead.footer = val;
this.letterhead._dirty = true;
// mixins
let { layout, letterhead, print_format } = useStore();
let store = inject("$store");
// methods
function add_section_above(section) {
let sections = [];
for (let _section of layout.value.sections) {
if (_section === section) {
sections.push({
label: "",
columns: [
{ label: "", fields: [] },
{ label: "", fields: [] }
]
});
}
sections.push(_section);
}
};
layout.value["sections"] = sections;
}
function update_letterhead_footer(val) {
letterhead.value.footer = val;
}
// computed
let rootStyles = computed(() => {
let {
margin_top = 0,
margin_bottom = 0,
margin_left = 0,
margin_right = 0
} = print_format.value;
return {
padding: `${margin_top}mm ${margin_right}mm ${margin_bottom}mm ${margin_left}mm`,
width: "210mm",
minHeight: "297mm"
};
});
let page_number_style = computed(() => {
let style = {
position: "absolute",
background: "white",
padding: "4px",
borderRadius: "var(--border-radius)",
border: "1px solid var(--border-color)"
};
if (print_format.value.page_number.includes("Top")) {
style.top = print_format.value.margin_top / 2 + "mm";
style.transform = "translateY(-50%)";
}
if (print_format.value.page_number.includes("Left")) {
style.left = print_format.value.margin_left + "mm";
}
if (print_format.value.page_number.includes("Right")) {
style.right = print_format.value.margin_right + "mm";
}
if (print_format.value.page_number.includes("Bottom")) {
style.bottom = print_format.value.margin_bottom / 2 + "mm";
style.transform = "translateY(50%)";
}
if (print_format.value.page_number.includes("Center")) {
style.left = "50%";
style.transform += " translateX(-50%)";
}
if (print_format.value.page_number.includes("Hide")) {
style.display = "none";
}
return style;
});
watch(layout, () => (store.dirty.value = true), { deep: true });
watch(print_format, () => (store.dirty.value = true), { deep: true });
</script>
<style scoped>

View file

@ -4,64 +4,59 @@
<PrintFormatControls />
</div>
<div class="print-format-container col-9">
<keep-alive>
<Preview v-if="show_preview" />
<PrintFormat v-else />
</keep-alive>
<KeepAlive>
<component :is="Preview" v-if="show_preview" />
<component :is="PrintFormat" v-else />
</KeepAlive>
</div>
</div>
</template>
<script>
<script setup>
import PrintFormat from "./PrintFormat.vue";
import Preview from "./Preview.vue";
import PrintFormatControls from "./PrintFormatControls.vue";
import { getStore } from "./store";
import { computed, ref, onMounted, provide } from "vue";
export default {
name: "PrintFormatBuilder",
props: ["print_format_name"],
components: {
PrintFormat,
PrintFormatControls,
Preview
},
data() {
return {
show_preview: false
};
},
provide() {
return {
$store: this.$store
};
},
mounted() {
this.$store.fetch().then(() => {
if (!this.$store.layout) {
this.$store.layout = this.$store.get_default_layout();
this.$store.save_changes();
}
});
},
methods: {
toggle_preview() {
this.show_preview = !this.show_preview;
// props
let props = defineProps(["print_format_name"]);
// variables
let show_preview = ref(false);
// computed
let $store = computed(() => {
return getStore(props.print_format_name)
});
let shouldRender = computed(() => {
return Boolean(
$store.value.print_format.value &&
$store.value.meta.value &&
$store.value.layout.value
);
});
// provide
provide("$store", $store.value);
// methods
function toggle_preview() {
show_preview.value = !show_preview.value;
}
// mounted
onMounted(() => {
$store.value.fetch().then(() => {
if (!$store.value.layout.value) {
$store.value.layout.value = $store.value.get_default_layout();
$store.value.save_changes();
}
},
computed: {
$store() {
return getStore(this.print_format_name);
},
shouldRender() {
return Boolean(
this.$store.print_format &&
this.$store.meta &&
this.$store.layout
);
}
}
};
});
});
defineExpose({ toggle_preview, $store });
</script>
<style scoped>

View file

@ -109,182 +109,184 @@
:group="{ name: 'fields', pull: 'clone', put: false }"
:sort="false"
:clone="clone_field"
item-key="id"
>
<div
class="field"
v-for="df in fields"
:key="df.fieldname"
:title="df.fieldname"
>
{{ df.label }}
</div>
<template #item="{ element }">
<div
class="field"
:title="element.fieldname"
>
{{ element.label }}
</div>
</template>
</draggable>
</div>
</div>
</div>
</template>
<script>
<script setup>
import draggable from "vuedraggable";
import { get_table_columns, pluck } from "./utils";
import { storeMixin } from "./store";
import { useStore } from "./store";
import { computed, onMounted, ref, watch, inject } from "vue";
export default {
name: "PrintFormatControls",
mixins: [storeMixin],
data() {
return {
search_text: "",
google_fonts: []
};
},
components: {
draggable
},
mounted() {
let method =
"frappe.printing.page.print_format_builder_beta.print_format_builder_beta.get_google_fonts";
frappe.call(method).then(r => {
this.google_fonts = r.message || [];
if (!this.google_fonts.includes(this.print_format.font)) {
this.google_fonts.push(this.print_format.font);
}
});
},
methods: {
update_margin(fieldname, value) {
value = parseFloat(value);
if (value < 0) {
value = 0;
}
this.$store.print_format[fieldname] = value;
},
clone_field(df) {
let cloned = pluck(df, [
"label",
"fieldname",
"fieldtype",
"options",
"table_columns",
"html",
"field_template"
]);
if (cloned.custom) {
// generate unique fieldnames for custom blocks
cloned.fieldname += "_" + frappe.utils.get_random(8);
}
return cloned;
}
},
computed: {
margins() {
return [
{ label: __("Top"), fieldname: "margin_top" },
{ label: __("Bottom"), fieldname: "margin_bottom" },
{ label: __("Left", null, 'alignment'), fieldname: "margin_left" },
{ label: __("Right", null, 'alignment'), fieldname: "margin_right" }
];
},
fields() {
let fields = this.meta.fields
.filter(df => {
if (
["Section Break", "Column Break"].includes(df.fieldtype)
) {
return false;
}
if (this.search_text) {
if (df.fieldname.includes(this.search_text)) {
return true;
}
if (df.label && df.label.includes(this.search_text)) {
return true;
}
return false;
} else {
return true;
}
})
.map(df => {
let out = {
label: df.label,
fieldname: df.fieldname,
fieldtype: df.fieldtype,
options: df.options
};
if (df.fieldtype == "Table") {
out.table_columns = get_table_columns(df);
}
return out;
});
// variables
let search_text = ref("");
let google_fonts = ref([]);
return [
{
label: __("Custom HTML"),
fieldname: "custom_html",
fieldtype: "HTML",
html: "",
custom: 1
},
{
label: __("ID (name)"),
fieldname: "name",
fieldtype: "Data"
},
{
label: __("Spacer"),
fieldname: "spacer",
fieldtype: "Spacer",
custom: 1
},
{
label: __("Divider"),
fieldname: "divider",
fieldtype: "Divider",
custom: 1
},
...this.print_templates,
...fields
];
},
print_templates() {
let templates = this.print_format.__onload.print_templates || {};
let out = [];
for (let template of templates) {
let df;
if (template.field) {
df = frappe.meta.get_docfield(
this.meta.name,
template.field
);
} else {
df = {
label: template.name,
fieldname: frappe.scrub(template.name)
};
// inject
let store = inject("$store");
// mixins
let { meta, print_format } = useStore();
// methods
function update_margin(fieldname, value) {
value = parseFloat(value);
if (value < 0) {
value = 0;
}
print_format.value[fieldname] = value;
}
function clone_field(df) {
let cloned = pluck(df, [
"label",
"fieldname",
"fieldtype",
"options",
"table_columns",
"html",
"field_template"
]);
if (cloned.custom) {
// generate unique fieldnames for custom blocks
cloned.fieldname += "_" + frappe.utils.get_random(8);
}
return cloned;
}
// computed
let margins = computed(() => {
return [
{ label: __("Top"), fieldname: "margin_top" },
{ label: __("Bottom"), fieldname: "margin_bottom" },
{ label: __("Left", null, 'alignment'), fieldname: "margin_left" },
{ label: __("Right", null, 'alignment'), fieldname: "margin_right" }
];
});
let fields = computed(() => {
let fields = meta.value.fields
.filter(df => {
if (
["Section Break", "Column Break"].includes(df.fieldtype)
) {
return false;
}
if (search_text.value) {
if (df.fieldname.includes(search_text.value)) {
return true;
}
out.push({
label: `${__(df.label)} (${__("Field Template")})`,
fieldname: df.fieldname + "_template",
fieldtype: "Field Template",
field_template: template.name
});
if (df.label && df.label.includes(search_text.value)) {
return true;
}
return false;
} else {
return true;
}
})
.map(df => {
let out = {
label: df.label,
fieldname: df.fieldname,
fieldtype: df.fieldtype,
options: df.options
};
if (df.fieldtype == "Table") {
out.table_columns = get_table_columns(df);
}
return out;
});
return [
{
label: __("Custom HTML"),
fieldname: "custom_html",
fieldtype: "HTML",
html: "",
custom: 1
},
page_number_positions() {
return [
{ label: __("Hide"), value: "Hide" },
{ label: __("Top Left"), value: "Top Left" },
{ label: __("Top Center"), value: "Top Center" },
{ label: __("Top Right"), value: "Top Right" },
{ label: __("Bottom Left"), value: "Bottom Left" },
{ label: __("Bottom Center"), value: "Bottom Center" },
{ label: __("Bottom Right"), value: "Bottom Right" }
];
{
label: __("ID (name)"),
fieldname: "name",
fieldtype: "Data"
},
{
label: __("Spacer"),
fieldname: "spacer",
fieldtype: "Spacer",
custom: 1
},
{
label: __("Divider"),
fieldname: "divider",
fieldtype: "Divider",
custom: 1
},
...print_templates.value,
...fields
];
});
let print_templates = computed(() => {
let templates = print_format.value.__onload.print_templates || {};
let out = [];
for (let template of templates) {
let df;
if (template.field) {
df = frappe.meta.get_docfield(
meta.value.name,
template.field
);
} else {
df = {
label: template.name,
fieldname: frappe.scrub(template.name)
};
}
out.push({
label: `${__(df.label)} (${__("Field Template")})`,
fieldname: df.fieldname + "_template",
fieldtype: "Field Template",
field_template: template.name
});
}
};
return out;
});
let page_number_positions = computed(() => {
return [
{ label: __("Hide"), value: "Hide" },
{ label: __("Top Left"), value: "Top Left" },
{ label: __("Top Center"), value: "Top Center" },
{ label: __("Top Right"), value: "Top Right" },
{ label: __("Bottom Left"), value: "Bottom Left" },
{ label: __("Bottom Center"), value: "Bottom Center" },
{ label: __("Bottom Right"), value: "Bottom Right" }
];
});
// mounted
onMounted(() => {
let method =
"frappe.printing.page.print_format_builder_beta.print_format_builder_beta.get_google_fonts";
frappe.call(method).then(r => {
google_fonts.value = r.message || [];
if (!google_fonts.value.includes(print_format.value.font)) {
google_fonts.value.push(print_format.value.font);
}
});
});
watch(print_format, () => (store.dirty.value = true), { deep: true });
</script>
<style scoped>

View file

@ -59,12 +59,11 @@
v-model="column.fields"
group="fields"
:animation="150"
item-key="id"
>
<Field
v-for="df in get_fields(column)"
:key="df.fieldname"
:df="df"
/>
<template #item="{ element }">
<Field :df="element" />
</template>
</draggable>
</div>
</div>
@ -78,102 +77,90 @@
</div>
</template>
<script>
<script setup>
import draggable from "vuedraggable";
import Field from "./Field.vue";
import { storeMixin } from "./store";
import { computed } from "vue";
export default {
name: "PrintFormatSection",
mixins: [storeMixin],
props: ["section"],
components: {
draggable,
Field
},
methods: {
add_column() {
if (this.section.columns.length < 4) {
this.section.columns.push({
label: "",
fields: []
});
}
},
remove_column() {
if (this.section.columns.length <= 1) return;
// props
let props = defineProps(["section"]);
let columns = this.section.columns.slice();
let last_column_fields = columns.slice(-1)[0].fields.slice();
let index = columns.length - 1;
columns = columns.slice(0, index);
let last_column = columns[index - 1];
last_column.fields = [...last_column.fields, ...last_column_fields];
// emits
let emit = defineEmits(["add_section_above"]);
this.$set(this.section, "columns", columns);
},
add_page_break() {
this.$set(this.section, "page_break", true);
},
remove_page_break() {
this.$set(this.section, "page_break", false);
},
get_fields(column) {
return column.fields.filter(df => !df.remove);
}
},
computed: {
section_options() {
return [
{
label: __("Add section above"),
action: () => this.$emit("add_section_above")
},
{
label: __("Add column"),
action: this.add_column,
condition: () => this.section.columns.length < 4
},
{
label: __("Remove column"),
action: this.remove_column,
condition: () => this.section.columns.length > 1
},
{
label: __("Add page break"),
action: this.add_page_break,
condition: () => !this.section.page_break
},
{
label: __("Remove page break"),
action: this.remove_page_break,
condition: () => this.section.page_break
},
{
label: __("Remove section"),
action: () => this.$set(this.section, "remove", true)
},
{
label: __("Field Orientation (Left-Right)"),
condition: () => !this.section.field_orientation,
action: () =>
this.$set(
this.section,
"field_orientation",
"left-right"
)
},
{
label: __("Field Orientation (Top-Down)"),
condition: () =>
this.section.field_orientation == "left-right",
action: () =>
this.$set(this.section, "field_orientation", "")
}
].filter(option => (option.condition ? option.condition() : true));
}
// methods
function add_column() {
if (props.section.columns.length < 4) {
props.section.columns.push({
label: "",
fields: []
});
}
};
}
function remove_column() {
if (props.section.columns.length <= 1) return;
let columns = props.section.columns.slice();
let last_column_fields = columns.slice(-1)[0].fields.slice();
let index = columns.length - 1;
columns = columns.slice(0, index);
let last_column = columns[index - 1];
last_column.fields = [...last_column.fields, ...last_column_fields];
props.section["columns"] = columns;
}
function add_page_break() {
props.section["page_break"] = true;
}
function remove_page_break() {
props.section["page_break"] = false;
}
// computed
let section_options = computed(() => {
return [
{
label: __("Add section above"),
action: () => emit("add_section_above")
},
{
label: __("Add column"),
action: add_column,
condition: () => props.section.columns.length < 4
},
{
label: __("Remove column"),
action: remove_column,
condition: () => props.section.columns.length > 1
},
{
label: __("Add page break"),
action: add_page_break,
condition: () => !props.section.page_break
},
{
label: __("Remove page break"),
action: remove_page_break,
condition: () => props.section.page_break
},
{
label: __("Remove section"),
action: () => { props.section["remove"] = true }
},
{
label: __("Field Orientation (Left-Right)"),
condition: () => !props.section.field_orientation,
action: () => { props.section["field_orientation"] = "left-right" }
},
{
label: __("Field Orientation (Top-Down)"),
condition: () =>
props.section.field_orientation == "left-right",
action: () => { props.section["field_orientation"] = "" }
}
].filter(option => (option.condition ? option.condition() : true));
})
</script>
<style scoped>

View file

@ -1,5 +1,5 @@
import { createApp } from "vue";
import PrintFormatBuilderComponent from "./PrintFormatBuilder.vue";
import { getStore } from "./store";
class PrintFormatBuilder {
constructor({ wrapper, page, print_format }) {
@ -28,28 +28,26 @@ class PrintFormatBuilder {
frappe.set_route("print-format-builder-beta");
});
let $vm = new Vue({
el: this.$wrapper.get(0),
render: (h) =>
h(PrintFormatBuilderComponent, {
props: {
print_format_name: print_format,
},
}),
});
this.$component = $vm.$children[0];
let store = getStore(print_format);
store.$watch("dirty", (value) => {
if (value) {
this.page.set_indicator("Not Saved", "orange");
$toggle_preview_btn.hide();
$reset_changes_btn.show();
} else {
this.page.clear_indicator();
$toggle_preview_btn.show();
$reset_changes_btn.hide();
}
});
let app = createApp(PrintFormatBuilderComponent, { print_format_name: print_format });
SetVueGlobals(app);
this.$component = app.mount(this.$wrapper.get(0));
this.$component.$watch(
"$store.dirty",
(dirty) => {
if (dirty.value) {
this.page.set_indicator("Not Saved", "orange");
$toggle_preview_btn.hide();
$reset_changes_btn.show();
} else {
this.page.clear_indicator();
$toggle_preview_btn.show();
$reset_changes_btn.hide();
}
},
{ deep: true }
);
this.$component.$watch("show_preview", (value) => {
$toggle_preview_btn.text(value ? __("Hide Preview") : __("Show Preview"));
});

View file

@ -1,158 +1,159 @@
import { create_default_layout, pluck } from "./utils";
let stores = {};
import { watch, ref, inject, computed, nextTick } from "vue";
export function getStore(print_format_name) {
if (stores[print_format_name]) {
return stores[print_format_name];
}
// variables
let letterhead_name = ref(null);
let print_format = ref(null);
let letterhead = ref(null);
let doctype = ref(null);
let meta = ref(null);
let layout = ref(null);
let dirty = ref(false);
let edit_letterhead = ref(false);
let options = {
data() {
return {
print_format_name,
letterhead_name: null,
print_format: null,
letterhead: null,
doctype: null,
meta: null,
layout: null,
dirty: false,
edit_letterhead: false,
};
},
watch: {
layout: {
deep: true,
handler() {
this.dirty = true;
},
},
print_format: {
deep: true,
handler() {
this.dirty = true;
},
},
},
methods: {
fetch() {
return new Promise((resolve) => {
frappe.model.clear_doc("Print Format", this.print_format_name);
frappe.model.with_doc("Print Format", this.print_format_name, () => {
let print_format = frappe.get_doc("Print Format", this.print_format_name);
frappe.model.with_doctype(print_format.doc_type, () => {
this.meta = frappe.get_meta(print_format.doc_type);
this.print_format = print_format;
this.layout = this.get_layout();
this.$nextTick(() => (this.dirty = false));
this.edit_letterhead = false;
resolve();
});
});
// methods
function fetch() {
return new Promise((resolve) => {
frappe.model.clear_doc("Print Format", print_format_name);
frappe.model.with_doc("Print Format", print_format_name, () => {
let _print_format = frappe.get_doc("Print Format", print_format_name);
frappe.model.with_doctype(_print_format.doc_type, () => {
meta.value = frappe.get_meta(_print_format.doc_type);
print_format.value = _print_format;
layout.value = get_layout();
nextTick(() => (dirty.value = false));
edit_letterhead.value = false;
resolve();
});
},
update({ fieldname, value }) {
this.$set(this.print_format, fieldname, value);
},
save_changes() {
frappe.dom.freeze(__("Saving..."));
});
});
}
function update({ fieldname, value }) {
print_format.value[fieldname] = value;
}
function save_changes() {
frappe.dom.freeze(__("Saving..."));
this.layout.sections = this.layout.sections
.filter((section) => !section.remove)
.map((section) => {
section.columns = section.columns.map((column) => {
column.fields = column.fields
.filter((df) => !df.remove)
.map((df) => {
if (df.table_columns) {
df.table_columns = df.table_columns.map((tf) => {
return pluck(tf, [
"label",
"fieldname",
"fieldtype",
"options",
"width",
"field_template",
]);
});
}
return pluck(df, [
layout.value.sections = layout.value.sections
.filter((section) => !section.remove)
.map((section) => {
section.columns = section.columns.map((column) => {
column.fields = column.fields
.filter((df) => !df.remove)
.map((df) => {
if (df.table_columns) {
df.table_columns = df.table_columns.map((tf) => {
return pluck(tf, [
"label",
"fieldname",
"fieldtype",
"options",
"table_columns",
"html",
"width",
"field_template",
]);
});
return column;
}
return pluck(df, [
"label",
"fieldname",
"fieldtype",
"options",
"table_columns",
"html",
"field_template",
]);
});
return section;
});
this.print_format.format_data = JSON.stringify(this.layout);
frappe
.call("frappe.client.save", {
doc: this.print_format,
})
.then(() => {
if (this.letterhead && this.letterhead._dirty) {
return frappe
.call("frappe.client.save", {
doc: this.letterhead,
})
.then((r) => (this.letterhead = r.message));
}
})
.then(() => this.fetch())
.always(() => {
frappe.dom.unfreeze();
this.$emit("after_save");
});
},
reset_changes() {
this.fetch();
},
get_layout() {
if (this.print_format) {
if (typeof this.print_format.format_data == "string") {
return JSON.parse(this.print_format.format_data);
}
return this.print_format.format_data;
}
return null;
},
get_default_layout() {
return create_default_layout(this.meta, this.print_format);
},
change_letterhead(letterhead) {
return frappe.db.get_doc("Letter Head", letterhead).then((doc) => {
this.letterhead = doc;
return column;
});
},
},
return section;
});
print_format.value.format_data = JSON.stringify(layout.value);
frappe
.call("frappe.client.save", {
doc: print_format.value,
})
.then(() => {
if (letterhead.value && letterhead.value._dirty) {
return frappe
.call("frappe.client.save", {
doc: letterhead.value,
})
.then((r) => (letterhead.value = r.message));
}
})
.then(() => fetch())
.always(() => {
frappe.dom.unfreeze();
});
}
function reset_changes() {
fetch();
}
function get_layout() {
if (print_format.value) {
if (typeof print_format.value.format_data == "string") {
return JSON.parse(print_format.value.format_data);
}
return print_format.value.format_data;
}
return null;
}
function get_default_layout() {
return create_default_layout(meta.value, print_format.value);
}
function change_letterhead(_letterhead) {
return frappe.db.get_doc("Letter Head", _letterhead).then((doc) => {
letterhead.value = doc;
});
}
// watch
watch(layout, () => {
dirty.value = true;
});
watch(print_format, () => {
dirty.value = true;
});
return {
letterhead_name,
print_format,
letterhead,
doctype,
meta,
layout,
dirty,
edit_letterhead,
fetch,
update,
save_changes,
reset_changes,
get_layout,
get_default_layout,
change_letterhead,
};
stores[print_format_name] = new Vue(options);
return stores[print_format_name];
}
export let storeMixin = {
inject: ["$store"],
computed: {
print_format() {
return this.$store.print_format;
},
layout() {
return this.$store.layout;
},
letterhead() {
return this.$store.letterhead;
},
meta() {
return this.$store.meta;
},
},
};
export function useStore() {
// inject store
let store = ref(inject("$store"));
// computed
let print_format = computed(() => {
return store.value.print_format;
});
let layout = computed(() => {
return store.value.layout;
});
let letterhead = computed(() => {
return store.value.letterhead;
});
let meta = computed(() => {
return store.value.meta;
});
return { print_format, layout, letterhead, meta, store };
}

View file

@ -1 +0,0 @@
import "./frappe/recorder/recorder";

View file

@ -159,11 +159,13 @@
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-evenly;
gap: 20px;
.summary-item {
// SIZE & SPACING
margin: 0px 30px;
min-width: 180px;
margin: 0px 20px;
min-width: 160px;
max-width: 300px;
height: 62px;

View file

@ -19,7 +19,7 @@
{{ item }}
{% endfor %}
</div>
<div class="more-block mt-6 {% if not show_more -%} hidden {%- endif %}">
<div class="more-block py-6 {% if not show_more -%} hidden {%- endif %}">
<button class="btn btn-light btn-more btn-sm">{{ _("More") }}</button>
</div>
</div>

View file

@ -181,7 +181,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
{% if no_of_cols >= 3 %}{{ "" }}
{%- elif df.align -%}{{ "text-" + df.align }}
{%- elif df.fieldtype in ("Int", "Float", "Currency", "Percent") -%}{{ "text-right" }}
{%- elif df.fieldtype in ("Check") -%}{{ "text-center" }}
{%- elif df.fieldtype in ("Check",) -%}{{ "text-center" }}
{%- else -%}{{ "" }}
{%- endif -%}
{% endmacro %}

Some files were not shown because too many files have changed in this diff Show more