Merge branch 'develop' of https://github.com/frappe/frappe into bg-submissions
This commit is contained in:
commit
fdf1ed9656
134 changed files with 3657 additions and 12768 deletions
2
.github/helper/documentation.py
vendored
2
.github/helper/documentation.py
vendored
|
|
@ -1,7 +1,7 @@
|
|||
import sys
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
docs_repos = [
|
||||
"frappe_docs",
|
||||
|
|
|
|||
3
.github/workflows/server-mariadb-tests.yml
vendored
3
.github/workflows/server-mariadb-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/server-postgres-tests.yml
vendored
3
.github/workflows/server-postgres-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/ui-tests.yml
vendored
7
.github/workflows/ui-tests.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
22
codecov.yml
22
codecov.yml
|
|
@ -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/*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
59
esbuild/frappe-vue-style.js
Normal file
59
esbuild/frappe-vue-style.js
Normal 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;
|
||||
}
|
||||
229
frappe/api.py
229
frappe/api.py
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`"""
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]}],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:"):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import frappe
|
||||
import json
|
||||
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
if frappe.db.exists("Social Login Key", "github"):
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
` > <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,
|
||||
` > <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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
8
frappe/public/js/frappe/recorder/recorder.bundle.js
Normal file
8
frappe/public/js/frappe/recorder/recorder.bundle.js
Normal 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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
28
frappe/public/js/frappe/recorder/router.js
Normal file
28
frappe/public/js/frappe/recorder/router.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
80
frappe/public/js/frappe/ui/toolbar/subscription.js
Normal file
80
frappe/public/js/frappe/ui/toolbar/subscription.js
Normal 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();
|
||||
},
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
import "./frappe/recorder/recorder";
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue