Merge branch 'develop' into undebounce-autocomplete

This commit is contained in:
barredterra 2024-06-20 16:09:10 +02:00
commit 0b95b28bfe
102 changed files with 13633 additions and 19460 deletions

View file

@ -30,7 +30,7 @@ context("Navigation", () => {
cy.get("@reload").get(".page-card .btn-primary").contains("Login").click();
cy.location("pathname").should("eq", "/login");
cy.login();
cy.visit("/app");
cy.reload().as("reload");
cy.location("pathname").should("eq", "/app/todo");
});
});

View file

@ -422,7 +422,7 @@ def get_site_config(sites_path: str | None = None, site_path: str | None = None)
from frappe.database.mariadb.database import MariaDBDatabase
return {
"mariadb": MariaDBDatabase.default_port, # 3306
"mariadb": MariaDBDatabase.default_port,
"postgres": 5432,
}[db_type]
@ -435,7 +435,7 @@ def get_site_config(sites_path: str | None = None, site_path: str | None = None)
config["db_type"] = os.environ.get("FRAPPE_DB_TYPE") or config.get("db_type") or "mariadb"
config["db_socket"] = os.environ.get("FRAPPE_DB_SOCKET") or config.get("db_socket")
config["db_host"] = os.environ.get("FRAPPE_DB_HOST") or config.get("db_host") or "127.0.0.1"
config["db_port"] = (
config["db_port"] = int(
os.environ.get("FRAPPE_DB_PORT") or config.get("db_port") or db_default_ports(config["db_type"])
)
@ -2572,8 +2572,8 @@ def _register_fault_handler():
import io
# Some libraries monkey patch stderr, we need actual fd
if isinstance(sys.stderr, io.TextIOWrapper):
faulthandler.register(signal.SIGUSR1, file=sys.stderr)
if isinstance(sys.__stderr__, io.TextIOWrapper):
faulthandler.register(signal.SIGUSR1, file=sys.__stderr__)
from frappe.utils.error import log_error

View file

@ -115,6 +115,11 @@ def delete_doc(doctype: str, name: str):
return "ok"
def get_meta(doctype: str):
frappe.only_for("All")
return frappe.get_meta(doctype)
def execute_doc_method(doctype: str, name: str, method: str | None = None):
"""Get a document from DB and execute method on it.
@ -188,6 +193,6 @@ url_rules = [
endpoint=execute_doc_method,
),
# Collection level APIs
Rule("/doctype/<doctype>/meta", methods=["GET"], endpoint=frappe.get_meta),
Rule("/doctype/<doctype>/meta", methods=["GET"], endpoint=get_meta),
Rule("/doctype/<doctype>/count", methods=["GET"], endpoint=count),
]

View file

@ -79,7 +79,6 @@ def after_response_wrapper(app):
app(environ, start_response),
(
frappe.rate_limiter.update,
frappe.monitor.stop,
frappe.recorder.dump,
frappe.request.after_response.run,
frappe.destroy,

View file

@ -421,7 +421,18 @@ def clear_cookies():
def validate_ip_address(user):
"""check if IP Address is valid"""
"""
Method to check if the user has IP restrictions enabled, and if so is the IP address they are
connecting from allowlisted.
Certain methods called from our socketio backend need direct access, and so the IP is not
checked for those
"""
if hasattr(frappe.local, "request") and frappe.local.request.path.startswith(
"/api/method/frappe.realtime."
):
return True
from frappe.core.doctype.user.user import get_restricted_ip_list
# Only fetch required fields - for perf

View file

@ -120,7 +120,10 @@ def get_bootinfo():
def get_letter_heads():
letter_heads = {}
for letter_head in frappe.get_all("Letter Head", fields=["name", "content", "footer"]):
if not frappe.has_permission("Letter Head"):
return letter_heads
for letter_head in frappe.get_list("Letter Head", fields=["name", "content", "footer"]):
letter_heads.setdefault(
letter_head.name, {"header": letter_head.content, "footer": letter_head.footer}
)

View file

@ -174,8 +174,11 @@ def purge_jobs(site=None, queue=None, event=None):
@click.command("schedule")
def start_scheduler():
"""Start scheduler process which is responsible for enqueueing the scheduled job types."""
import time
from frappe.utils.scheduler import start_scheduler
time.sleep(0.5) # Delayed start. TODO: find better way to handle this.
start_scheduler()
@ -222,12 +225,7 @@ def start_worker_pool(queue, quiet=False, num_workers=2, burst=False):
"""Start a pool of background workers"""
from frappe.utils.background_jobs import start_worker_pool
start_worker_pool(
queue=queue,
quiet=quiet,
burst=burst,
num_workers=num_workers,
)
start_worker_pool(queue=queue, quiet=quiet, burst=burst, num_workers=num_workers)
@click.command("ready-for-migration")

View file

@ -1501,6 +1501,33 @@ def add_new_user(
update_password(user=user.name, pwd=password)
@click.command("bypass-patch")
@click.argument("patch_name")
@click.option("--yes", "-y", is_flag=True, default=False, help="Pass --yes to skip confirmation")
@pass_context
def bypass_patch(context, patch_name: str, yes: bool):
"""Bypass a patch permanently instead of migrating using the --skip-failing flag."""
from frappe.modules.patch_handler import update_patch_log
if not context.sites:
raise SiteNotSpecifiedError
if not yes:
click.confirm(
f"This will bypass the patch {patch_name!r} forever and register it as successful.\nAre you sure you want to continue?",
abort=True,
)
for site in context.sites:
frappe.init(site=site)
frappe.connect()
try:
update_patch_log(patch_name)
frappe.db.commit()
finally:
frappe.destroy()
commands = [
add_system_manager,
add_user_for_sites,
@ -1535,4 +1562,5 @@ commands = [
trim_tables,
trim_database,
clear_log_table,
bypass_patch,
]

View file

@ -288,9 +288,21 @@ def address_query(doctype, txt, searchfield, start, page_len, filters):
else:
search_condition += f" or `tabAddress`.`{field}` like %(txt)s"
# Use custom title field if set
if meta.show_title_field_in_link and meta.title_field:
title = f"`tabAddress`.{meta.title_field}"
else:
title = "`tabAddress`.city"
# Get additional search fields
if searchfields:
extra_query_fields = ",".join([f"`tabAddress`.{field}" for field in searchfields])
else:
extra_query_fields = "`tabAddress`.country"
return frappe.db.sql(
"""select
`tabAddress`.name, `tabAddress`.city, `tabAddress`.country
`tabAddress`.name, {title}, {extra_query_fields}
from
`tabAddress`
join `tabDynamic Link`
@ -312,6 +324,8 @@ def address_query(doctype, txt, searchfield, start, page_len, filters):
mcond=get_match_cond(doctype),
search_condition=search_condition,
condition=condition or "",
title=title,
extra_query_fields=extra_query_fields,
),
{
"txt": "%" + txt + "%",

View file

@ -53,7 +53,7 @@ class Contact(Document):
# concat party name if reqd
for link in self.links:
self.name = self.name + "-" + link.link_name.strip()
self.name = self.name + "-" + cstr(link.link_name).strip()
break
if frappe.db.exists("Contact", self.name):
@ -129,6 +129,9 @@ class Contact(Document):
if len([email.email_id for email in self.email_ids if email.is_primary]) > 1:
frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold("Email ID")))
if len(self.email_ids) == 1:
self.email_ids[0].is_primary = 1
primary_email_exists = False
for d in self.email_ids:
if d.is_primary == 1:

View file

@ -118,7 +118,6 @@ class Exporter:
for doc in data:
rows = []
rows = self.add_data_row(self.doctype, None, doc, rows, 0)
if table_fields:
# add child table data
for f in table_fields:
@ -144,6 +143,8 @@ class Exporter:
if df.fieldtype == "Duration":
value = format_duration(flt(value), df.hide_days)
if df.fieldtype == "Text Editor":
value = frappe.core.utils.html2text(value)
row[i] = value
return rows

View file

@ -870,8 +870,10 @@ class DocType(Document):
def make_amendable(self):
"""If is_submittable is set, add amended_from docfields."""
if self.is_submittable:
docfield_exists = [f for f in self.fields if f.fieldname == "amended_from"]
if not docfield_exists:
docfield = [f for f in self.fields if f.fieldname == "amended_from"]
if docfield:
docfield[0].options = self.name
else:
self.append(
"fields",
{
@ -903,7 +905,7 @@ class DocType(Document):
no_copy=1,
print_hide=1,
)
create_custom_field(self.name, df)
create_custom_field(self.name, df, ignore_validate=True)
def validate_nestedset(self):
if not self.get("is_tree"):
@ -1558,9 +1560,21 @@ def validate_fields(meta: Meta):
options_list.append(_option)
field.options = "\n".join(options_list)
def scrub_fetch_from(field):
if hasattr(field, "fetch_from") and field.fetch_from:
field.fetch_from = field.fetch_from.strip("\n").strip()
def validate_fetch_from(field):
if not field.get("fetch_from"):
return
field.fetch_from = field.fetch_from.strip()
if "." not in field.fetch_from:
return
source_field, _target_field = field.fetch_from.split(".", maxsplit=1)
if source_field == field.fieldname:
msg = _(
"{0} contains an invalid Fetch From expression, Fetch From can't be self-referential."
).format(_(field.label, context=field.parent))
frappe.throw(msg, title=_("Recursive Fetch From"))
def validate_data_field_type(docfield):
if docfield.get("is_virtual"):
@ -1636,7 +1650,7 @@ def validate_fields(meta: Meta):
check_unique_and_text(meta.get("name"), d)
check_table_multiselect_option(d)
scrub_options_in_select(d)
scrub_fetch_from(d)
validate_fetch_from(d)
validate_data_field_type(d)
if not frappe.flags.in_migrate or in_ci:

View file

@ -774,6 +774,19 @@ class TestDocType(FrappeTestCase):
self.assertTrue(doctype.fields[1].in_list_view)
frappe.delete_doc("DocType", doctype.name)
def test_no_recursive_fetch(self):
recursive_dt = new_doctype(
fields=[
{
"label": "User",
"fieldname": "user",
"fieldtype": "Link",
"fetch_from": "user.email",
}
],
)
self.assertRaises(frappe.ValidationError, recursive_dt.insert)
def new_doctype(
name: str | None = None,

View file

@ -48,6 +48,8 @@
{
"fieldname": "reference_name",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Reference Name",
"read_only": 1
},
@ -70,7 +72,7 @@
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2024-03-23 16:03:25.381120",
"modified": "2024-06-05 05:34:35.048489",
"modified_by": "Administrator",
"module": "Core",
"name": "Error Log",
@ -92,4 +94,4 @@
"sort_order": "DESC",
"states": [],
"title_field": "method"
}
}

View file

@ -24,6 +24,14 @@ class ErrorLog(Document):
trace_id: DF.Data | None
# end: auto-generated types
def validate(self):
self.method = str(self.method)
self.error = str(self.error)
if len(self.method) > 140:
self.error = f"{self.method}\n{self.error}"
self.method = self.method[:140]
def onload(self):
if not self.seen and not frappe.flags.read_only:
self.db_set("seen", 1, update_modified=0)

View file

@ -82,7 +82,16 @@ frappe.ui.form.on("File", {
if (frm.doc.file_name) {
file_url = file_url.replace(/#/g, "%23");
}
window.open(file_url);
// create temporary link element to simulate a download click
var link = document.createElement("a");
link.href = file_url;
link.download = frm.doc.file_name;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
optimize: function (frm) {

View file

@ -1,6 +1,5 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:language_code",
"creation": "2014-08-22 16:12:17.249590",
"doctype": "DocType",
@ -27,7 +26,8 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Language Name",
"reqd": 1
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "flag",
@ -51,7 +51,7 @@
"icon": "fa fa-globe",
"in_create": 1,
"links": [],
"modified": "2024-03-23 16:03:28.477169",
"modified": "2024-06-06 18:25:01.010821",
"modified_by": "Administrator",
"module": "Core",
"name": "Language",

View file

@ -122,5 +122,8 @@ class PackageRelease(Document):
attached_to_name=self.name,
)
# Set path to tarball
self.path = file.file_url
file.flags.ignore_duplicate_entry_error = True
file.insert()

View file

@ -114,9 +114,8 @@ def generate_report(prepared_report):
instance.status = "Completed"
except Exception:
instance.status = "Error"
instance.error_message = frappe.get_traceback(with_context=True)
_save_instance(instance) # we need to ensure that error gets stored
# we need to ensure that error gets stored
_save_error(instance, error=frappe.get_traceback(with_context=True))
instance.report_end_time = frappe.utils.now()
instance.save(ignore_permissions=True)
@ -129,7 +128,10 @@ def generate_report(prepared_report):
@dangerously_reconnect_on_connection_abort
def _save_instance(instance):
def _save_error(instance, error):
instance.reload()
instance.status = "Error"
instance.error_message = error
instance.save(ignore_permissions=True)

View file

@ -71,8 +71,9 @@ class ScheduledJobType(Document):
enqueue(
"frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job",
queue=self.get_queue_name(),
job_type=self.method,
job_type=self.method, # Not actually used, kept for logging
job_id=self.rq_job_id,
scheduled_job_type=self.name,
)
return True
else:
@ -93,7 +94,7 @@ class ScheduledJobType(Document):
@property
def rq_job_id(self):
"""Unique ID created to deduplicate jobs with single RQ call."""
return f"scheduled_job::{self.method}"
return f"scheduled_job::{self.name}"
@property
def next_execution(self):
@ -188,10 +189,10 @@ def execute_event(doc: str):
return doc
def run_scheduled_job(job_type: str):
def run_scheduled_job(scheduled_job_type: str, job_type: str | None = None):
"""This is a wrapper function that runs a hooks.scheduler_events method"""
try:
frappe.get_doc("Scheduled Job Type", dict(method=job_type)).execute()
frappe.get_doc("Scheduled Job Type", scheduled_job_type).execute()
except Exception:
print(frappe.get_traceback())
@ -199,8 +200,8 @@ def run_scheduled_job(job_type: str):
def sync_jobs(hooks: dict | None = None):
frappe.reload_doc("core", "doctype", "scheduled_job_type")
scheduler_events = hooks or frappe.get_hooks("scheduler_events")
all_events = insert_events(scheduler_events)
clear_events(all_events)
insert_events(scheduler_events)
clear_events(scheduler_events)
def insert_events(scheduler_events: dict) -> list:
@ -261,12 +262,19 @@ def insert_single_event(frequency: str, event: str, cron_format: str | None = No
doc.insert()
def clear_events(all_events: list):
for event in frappe.get_all("Scheduled Job Type", fields=["name", "method", "server_script"]):
is_server_script = event.server_script
is_defined_in_hooks = event.method in all_events
def clear_events(scheduler_events: dict):
def event_exists(event) -> bool:
if event.server_script:
return True
if not (is_defined_in_hooks or is_server_script):
freq = frappe.scrub(event.frequency)
if freq == "cron":
return event.method in scheduler_events.get(freq, {}).get(event.cron_format, [])
else:
return event.method in scheduler_events.get(freq, [])
for event in frappe.get_all("Scheduled Job Type", fields=["*"]):
if not event_exists(event):
frappe.delete_doc("Scheduled Job Type", event.name)

View file

@ -159,7 +159,7 @@ class User(Document):
if self.name not in STANDARD_USERS:
self.email = self.name
self.validate_email_type(self.name)
self.add_system_manager_role()
self.move_role_profile_name_to_role_profiles()
self.populate_role_profile_roles()
self.check_roles_added()
@ -279,9 +279,6 @@ class User(Document):
if not cint(self.enabled) and self.name in STANDARD_USERS:
frappe.throw(_("User {0} cannot be disabled").format(self.name))
if not cint(self.enabled):
self.a_system_manager_should_exist()
# clear sessions if disabled
if not cint(self.enabled) and getattr(frappe.local, "login_manager", None):
frappe.local.login_manager.logout(user=self.name)
@ -289,38 +286,6 @@ class User(Document):
# toggle notifications based on the user's status
toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True)
def add_system_manager_role(self):
if self.is_system_manager_disabled():
return
# if adding system manager, do nothing
if not cint(self.enabled) or (
"System Manager" in [user_role.role for user_role in self.get("roles")]
):
return
if (
self.name not in STANDARD_USERS
and self.user_type == "System User"
and not self.get_other_system_managers()
and cint(frappe.db.get_single_value("System Settings", "setup_complete"))
):
msgprint(_("Adding System Manager to this User as there must be atleast one System Manager"))
self.append("roles", {"doctype": "Has Role", "role": "System Manager"})
if self.name == "Administrator":
# Administrator should always have System Manager Role
self.extend(
"roles",
[
{"doctype": "Has Role", "role": "System Manager"},
{"doctype": "Has Role", "role": "Administrator"},
],
)
def is_system_manager_disabled(self):
return frappe.db.get_value("Role", {"name": "System Manager"}, ["disabled"])
def email_new_password(self, new_password=None):
if new_password and not self.flags.in_insert:
_update_password(user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions)
@ -430,20 +395,6 @@ class User(Document):
return link
def get_other_system_managers(self):
user_doctype = DocType("User").as_("user")
user_role_doctype = DocType("Has Role").as_("user_role")
return (
frappe.qb.from_(user_doctype)
.from_(user_role_doctype)
.select(user_doctype.name)
.where(user_role_doctype.role == "System Manager")
.where(user_doctype.enabled == 1)
.where(user_role_doctype.parent == user_doctype.name)
.where(user_role_doctype.parent.notin(["Administrator", self.name]))
.limit(1)
).run()
def get_fullname(self):
"""get first_name space last_name"""
return (self.first_name or "") + (self.first_name and " " or "") + (self.last_name or "")
@ -528,20 +479,11 @@ class User(Document):
retry=3,
)
def a_system_manager_should_exist(self):
if self.is_system_manager_disabled():
return
if not self.get_other_system_managers():
throw(_("There should remain at least one System Manager"))
def on_trash(self):
frappe.clear_cache(user=self.name)
if self.name in STANDARD_USERS:
throw(_("User {0} cannot be deleted").format(self.name))
self.a_system_manager_should_exist()
# disable the user and log him/her out
self.enabled = 0
if getattr(frappe.local, "login_manager", None):
@ -601,6 +543,10 @@ class User(Document):
frappe.throw(_("You can disable the user instead of deleting it."), frappe.LinkExistsError)
def before_rename(self, old_name, new_name, merge=False):
# if merging, delete the old user notification settings
if merge:
frappe.delete_doc("Notification Settings", old_name, ignore_permissions=True)
frappe.clear_cache(user=old_name)
self.validate_rename(old_name, new_name)

View file

@ -335,10 +335,14 @@ frappe.ui.form.on("Customize Form Field", {
},
});
let parenttype, parent; // used in the form events for the child tables: links, actions and states
// can't delete standard links
frappe.ui.form.on("DocType Link", {
before_links_remove: function (frm, doctype, name) {
let row = frappe.get_doc(doctype, name);
parenttype = row.parenttype; // used in the event links_remove
parent = row.parent; // used in the event links_remove
if (!(row.custom || row.__islocal)) {
frappe.msgprint(__("Cannot delete standard link. You can hide it if you want"));
throw "cannot delete standard link";
@ -348,12 +352,19 @@ frappe.ui.form.on("DocType Link", {
let f = frappe.model.get_doc(cdt, cdn);
f.custom = 1;
},
links_remove: function (frm, doctype, name) {
// replicate the changed rows from the browser's copy of the parent doc to the current 'Customize Form' doc
let parent_doc = locals[parenttype][parent];
frm.doc.links = parent_doc.links;
},
});
// can't delete standard actions
frappe.ui.form.on("DocType Action", {
before_actions_remove: function (frm, doctype, name) {
let row = frappe.get_doc(doctype, name);
parenttype = row.parenttype; // used in the event actions_remove
parent = row.parent; // used in the event actions_remove
if (!(row.custom || row.__islocal)) {
frappe.msgprint(__("Cannot delete standard action. You can hide it if you want"));
throw "cannot delete standard action";
@ -363,12 +374,19 @@ frappe.ui.form.on("DocType Action", {
let f = frappe.model.get_doc(cdt, cdn);
f.custom = 1;
},
actions_remove: function (frm, doctype, name) {
// replicate the changed rows from the browser's copy of the parent doc to the current 'Customize Form' doc
let parent_doc = locals[parenttype][parent];
frm.doc.actions = parent_doc.actions;
},
});
// can't delete standard states
frappe.ui.form.on("DocType State", {
before_states_remove: function (frm, doctype, name) {
let row = frappe.get_doc(doctype, name);
parenttype = row.parenttype; // used in the event states_remove
parent = row.parent; // used in the event states_remove
if (!(row.custom || row.__islocal)) {
frappe.msgprint(__("Cannot delete standard document state."));
throw "cannot delete standard document state";
@ -378,6 +396,11 @@ frappe.ui.form.on("DocType State", {
let f = frappe.model.get_doc(cdt, cdn);
f.custom = 1;
},
states_remove: function (frm, doctype, name) {
// replicate the changed rows from the browser's copy of the parent doc to the current 'Customize Form' doc
let parent_doc = locals[parenttype][parent];
frm.doc.states = parent_doc.states;
},
});
frappe.customize_form.save_customization = function (frm) {

View file

@ -200,7 +200,12 @@ class DbColumn:
self.not_nullable = not_nullable
def get_definition(self, for_modification=False):
column_def = get_definition(self.fieldtype, precision=self.precision, length=self.length)
column_def = get_definition(
self.fieldtype,
precision=self.precision,
length=self.length,
options=self.options,
)
if not column_def:
return column_def
@ -356,9 +361,20 @@ def validate_column_length(fieldname):
frappe.throw(_("Fieldname is limited to 64 characters ({0})").format(fieldname))
def get_definition(fieldtype, precision=None, length=None):
def get_definition(fieldtype, precision=None, length=None, *, options=None):
d = frappe.db.type_map.get(fieldtype)
if (
fieldtype == "Link"
and options
# XXX: This might not trigger if referred doctype is not yet created
# This is largely limitation of how migration happens though.
# Maybe we can sort by creation and then modified?
and frappe.db.exists("DocType", options)
and frappe.get_meta(options).autoname == "UUID"
):
d = ("uuid", None)
if not d:
return

View file

@ -469,7 +469,11 @@ def get_workspace_sidebar_items():
pages = [frappe.get_doc("Workspace", "Welcome Workspace").as_dict()]
pages[0]["label"] = _("Welcome Workspace")
return {"pages": pages, "has_access": has_access}
return {
"pages": pages,
"has_access": has_access,
"has_create_access": frappe.has_permission(doctype="Workspace", ptype="create"),
}
def get_table_with_counts():

View file

@ -243,6 +243,7 @@
"fieldname": "is_standard",
"fieldtype": "Check",
"label": "Is Standard",
"no_copy": 1,
"read_only_depends_on": "eval: !frappe.boot.developer_mode"
},
{
@ -288,7 +289,7 @@
}
],
"links": [],
"modified": "2024-03-23 16:02:16.230433",
"modified": "2024-06-03 13:29:57.960271",
"modified_by": "Administrator",
"module": "Desk",
"name": "Dashboard Chart",

View file

@ -147,7 +147,8 @@ def update_order_for_single_card(board_name, docname, from_colname, to_colname,
if from_colname == to_colname:
from_col_order = to_col_order
to_col_order.insert(new_index, from_col_order.pop(old_index))
if from_col_order:
to_col_order.insert(new_index, from_col_order.pop(old_index))
# save updated order
board.columns[from_col_idx].order = frappe.as_json(from_col_order)

View file

@ -31,7 +31,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Indicator",
"options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nRed\nYellow"
"options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nYellow"
},
{
"fieldname": "order",

View file

@ -15,7 +15,7 @@ class KanbanBoardColumn(Document):
column_name: DF.Data | None
indicator: DF.Literal[
"Blue", "Cyan", "Gray", "Green", "Light Blue", "Orange", "Pink", "Purple", "Red", "Red", "Yellow"
"Blue", "Cyan", "Gray", "Green", "Light Blue", "Orange", "Pink", "Purple", "Red", "Yellow"
]
order: DF.Code | None
parent: DF.Data

View file

@ -273,6 +273,9 @@ frappe.ui.form.on("Number Card", {
}
table.on("click", () => {
if (!frappe.boot.developer_mode && frm.doc.is_standard) {
frappe.throw(__("Cannot edit filters for standard number cards"));
}
let dialog = new frappe.ui.Dialog({
title: __("Set Filters"),
fields: fields.filter((f) => !is_dynamic_filter(f)),
@ -357,6 +360,9 @@ frappe.ui.form.on("Number Card", {
);
frm.dynamic_filter_table.on("click", () => {
if (!frappe.boot.developer_mode && frm.doc.is_standard) {
frappe.throw(__("Cannot edit filters for standard number cards"));
}
let dialog = new frappe.ui.Dialog({
title: __("Set Dynamic Filters"),
fields: fields,

View file

@ -90,15 +90,17 @@ class ToDo(Document):
return
try:
assignments = frappe.get_all(
assignments = frappe.db.get_values(
"ToDo",
filters={
{
"reference_type": self.reference_type,
"reference_name": self.reference_name,
"status": ("not in", ("Cancelled", "Closed")),
"allocated_to": ("is", "set"),
},
pluck="allocated_to",
"allocated_to",
pluck=True,
for_update=True,
)
assignments.reverse()

View file

@ -219,7 +219,7 @@
],
"in_create": 1,
"links": [],
"modified": "2024-03-23 16:04:05.604044",
"modified": "2024-05-30 17:30:36.791171",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace",
@ -237,6 +237,18 @@
"role": "Workspace Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Desk User",
"share": 1,
"write": 1
}
],
"sort_field": "creation",

View file

@ -23,9 +23,7 @@ class Workspace(Document):
if TYPE_CHECKING:
from frappe.core.doctype.has_role.has_role import HasRole
from frappe.desk.doctype.workspace_chart.workspace_chart import WorkspaceChart
from frappe.desk.doctype.workspace_custom_block.workspace_custom_block import (
WorkspaceCustomBlock,
)
from frappe.desk.doctype.workspace_custom_block.workspace_custom_block import WorkspaceCustomBlock
from frappe.desk.doctype.workspace_link.workspace_link import WorkspaceLink
from frappe.desk.doctype.workspace_number_card.workspace_number_card import WorkspaceNumberCard
from frappe.desk.doctype.workspace_quick_list.workspace_quick_list import WorkspaceQuickList
@ -251,6 +249,12 @@ def new_page(new_page):
):
frappe.throw(_("Cannot create private workspace of other users"), frappe.PermissionError)
elif not frappe.has_permission(doctype="Workspace", ptype="create"):
frappe.flags.error_message = _("User {0} does not have the permission to create a Workspace.").format(
frappe.bold(frappe.session.user)
)
raise frappe.PermissionError
doc = frappe.new_doc("Workspace")
doc.title = page.get("title")
doc.icon = page.get("icon")

View file

@ -220,6 +220,7 @@ def create_or_update_user(args): # nosemgrep
}
)
user.append_roles(*_get_default_roles())
user.append_roles("System Manager")
user.flags.no_welcome_mail = True
user.insert()
@ -305,7 +306,7 @@ def load_languages():
}
@frappe.whitelist()
@frappe.whitelist(allow_guest=True)
def load_country():
from frappe.sessions import get_geo_ip_country

View file

@ -34,6 +34,9 @@ def get_report_doc(report_name):
doc.custom_filters = data.get("filters")
doc.is_custom_report = True
# Follow whatever the custom report has set for prepared report field
doc.prepared_report = custom_report_doc.prepared_report
if not doc.is_permitted():
frappe.throw(
_("You don't have access to Report: {0}").format(report_name),

View file

@ -216,7 +216,8 @@ def update_wildcard_field_param(data):
if (isinstance(data.fields, str) and data.fields == "*") or (
isinstance(data.fields, list | tuple) and len(data.fields) == 1 and data.fields[0] == "*"
):
data.fields = get_permitted_fields(data.doctype, parenttype=data.parenttype)
parent_type = data.parenttype or data.parent_doctype
data.fields = get_permitted_fields(data.doctype, parenttype=parent_type, ignore_virtual=True)
return True
return False

View file

@ -347,7 +347,9 @@ def make_links(columns, data):
elif col.fieldtype == "Currency":
doc = None
if doc_name and col.get("parent") and not frappe.get_meta(col.parent).istable:
doc = frappe.get_doc(col.parent, doc_name)
if frappe.db.exists(col.parent, doc_name):
doc = frappe.get_doc(col.parent, doc_name)
# Pass the Document to get the currency based on docfield option
row[col.fieldname] = frappe.format_value(row[col.fieldname], col, doc=doc)
return columns, data

View file

@ -237,6 +237,7 @@ class SendMailContext:
self.sent_to_atleast_one_recipient = any(
rec.recipient for rec in self.queue_doc.recipients if rec.is_mail_sent()
)
self.email_account_doc = None
def fetch_smtp_server(self):
self.email_account_doc = self.queue_doc.get_email_account(raise_error=True)
@ -326,7 +327,11 @@ class SendMailContext:
}
tracker_url = get_url(f"{email_read_tracker_url}?{get_signed_params(params)}")
elif frappe.conf.use_ssl and self.email_account_doc.track_email_status:
elif (
self.email_account_doc
and self.email_account_doc.track_email_status
and self.queue_doc.communication
):
tracker_url = f"{get_url()}/api/method/frappe.core.doctype.communication.email.mark_email_as_seen?name={self.queue_doc.communication}"
if tracker_url:

View file

@ -300,3 +300,10 @@ class InvalidKeyError(ValidationError):
http_status_code = 401
title = "Invalid Key"
message = "The document key is invalid"
class CommandFailedError(Exception):
def __init__(self, message: str, out: str, err: str):
super().__init__(message)
self.out = out
self.err = err

View file

@ -194,11 +194,16 @@ doc_events = {
scheduler_events = {
"cron": {
# 15 minutes
"0/15 * * * *": [
"frappe.oauth.delete_oauth2_data",
"frappe.website.doctype.web_page.web_page.check_publish_status",
"frappe.twofactor.delete_all_barcodes_for_users",
"frappe.email.doctype.email_account.email_account.notify_unreplied",
"frappe.utils.global_search.sync_global_search",
"frappe.deferred_insert.save_to_db",
],
# 10 minutes
"0/10 * * * *": [
"frappe.email.doctype.email_account.email_account.pull",
],
@ -213,8 +218,6 @@ scheduler_events = {
},
"all": [
"frappe.email.queue.flush",
"frappe.email.doctype.email_account.email_account.notify_unreplied",
"frappe.utils.global_search.sync_global_search",
"frappe.monitor.flush",
"frappe.automation.doctype.reminder.reminder.send_reminders",
],
@ -222,7 +225,6 @@ scheduler_events = {
"frappe.model.utils.link_count.update_link_count",
"frappe.model.utils.user_settings.sync_user_settings",
"frappe.desk.page.backups.backups.delete_downloadable_backups",
"frappe.deferred_insert.save_to_db",
"frappe.desk.form.document_follow.send_hourly_updates",
"frappe.integrations.doctype.google_calendar.google_calendar.sync",
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email",
@ -371,6 +373,7 @@ global_search_doctypes = {
override_whitelisted_methods = {
# Legacy File APIs
"frappe.utils.file_manager.download_file": "download_file",
"frappe.core.doctype.file.file.download_file": "download_file",
"frappe.core.doctype.file.file.unzip_file": "frappe.core.api.file.unzip_file",
"frappe.core.doctype.file.file.get_attached_images": "frappe.core.api.file.get_attached_images",
@ -420,6 +423,10 @@ before_request = [
"frappe.rate_limiter.apply",
]
after_request = [
"frappe.monitor.stop",
]
# Background Job Hooks
before_job = [
"frappe.recorder.record",
@ -551,4 +558,5 @@ persistent_cache_keys = [
"changelog-*", # version update notifications
"insert_queue_for_*", # Deferred Insert
"recorder-*", # Recorder
"global_search_queue",
]

View file

@ -21,15 +21,15 @@ def make_request(method: str, url: str, auth=None, headers=None, data=None, json
)
response.raise_for_status()
content_type = response.headers.get("content-type")
if content_type == "text/plain; charset=utf-8":
return parse_qs(response.text)
elif content_type.startswith("application/") and content_type.split(";")[0].endswith("json"):
return response.json()
elif response.text:
return response.text
else:
return
# Check whether the response has a content-type, before trying to check what it is
if content_type := response.headers.get("content-type"):
if content_type == "text/plain; charset=utf-8":
return parse_qs(response.text)
elif content_type.startswith("application/") and content_type.split(";")[0].endswith("json"):
return response.json()
elif response.text:
return response.text
return
except Exception as exc:
if frappe.flags.integration_request_doc:
frappe.flags.integration_request_doc.log_error()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -136,17 +136,34 @@ class SiteMigration:
* Sync Installed Applications Version History
* Execute `after_migrate` hooks
"""
print("Syncing jobs...")
sync_jobs()
print("Syncing fixtures...")
sync_fixtures()
print("Syncing dashboards...")
sync_dashboards()
print("Syncing customizations...")
sync_customizations()
print("Syncing languages...")
sync_languages()
print("Flushing deferred inserts...")
flush_deferred_inserts()
print("Removing orphan doctypes...")
frappe.model.sync.remove_orphan_doctypes()
print("Syncing portal menu...")
frappe.get_single("Portal Settings").sync_menu()
print("Updating installed applications...")
frappe.get_single("Installed Applications").update_versions()
print("Executing `after_migrate` hooks...")
for app in frappe.get_installed_apps():
for fn in frappe.get_hooks("after_migrate", app_name=app):
frappe.get_attr(fn)()

View file

@ -669,7 +669,7 @@ class DatabaseQuery:
if wrap_grave_quotes(table) in self.query_tables:
permitted_child_table_fields = get_permitted_fields(
doctype=ch_doctype, parenttype=self.doctype
doctype=ch_doctype, parenttype=self.doctype, ignore_virtual=True
)
if column in permitted_child_table_fields or column in optional_fields:
continue
@ -728,7 +728,7 @@ class DatabaseQuery:
df = df[0] if df else None
# primary key is never nullable, modified is usually indexed by default and always present
can_be_null = f.fieldname not in ("name", "modified")
can_be_null = f.fieldname not in ("name", "modified", "creation")
value = None
@ -810,12 +810,20 @@ class DatabaseQuery:
if f.operator.lower() in ("previous", "next", "timespan"):
date_range = get_date_range(f.operator.lower(), f.value)
f.operator = "Between"
f.operator = "between"
f.value = date_range
fallback = f"'{FallBackDateTimeStr}'"
if f.operator.lower() in (">", ">=") and (
f.fieldname in ("creation", "modified")
or (df and (df.fieldtype == "Date" or df.fieldtype == "Datetime"))
):
# Null values can never be greater than any non-null value
can_be_null = False
if f.operator in (">", "<", ">=", "<=") and (f.fieldname in ("creation", "modified")):
value = cstr(f.value)
can_be_null = False
fallback = f"'{FallBackDateTimeStr}'"
elif f.operator.lower() in ("between") and (
@ -823,6 +831,17 @@ class DatabaseQuery:
or (df and (df.fieldtype == "Date" or df.fieldtype == "Datetime"))
):
escape = False
# Between operator never needs to check for null
# Explanation: Consider SQL -> `COLUMN between X and Y`
# Actual computation:
# for row in rows:
# if Y > row.COLUMN > X:
# yield row
# Since Y and X can't be null, null value in column will never match filter, so
# coalesce is extra cost that prevents index usage
can_be_null = False
value = get_between_date_filter(f.value, df)
fallback = f"'{FallBackDateTimeStr}'"

View file

@ -1 +0,0 @@
<svg width="264" height="88" viewBox="0 0 264 88" xmlns="http://www.w3.org/2000/svg"><title>default-skin 2</title><g fill="none" fill-rule="evenodd"><g><path d="M67.002 59.5v3.768c-6.307.84-9.184 5.75-10.002 9.732 2.22-2.83 5.564-5.098 10.002-5.098V71.5L73 65.585 67.002 59.5z" id="Shape" fill="#fff"/><g fill="#fff"><path d="M13 29v-5h2v3h3v2h-5zM13 15h5v2h-3v3h-2v-5zM31 15v5h-2v-3h-3v-2h5zM31 29h-5v-2h3v-3h2v5z" id="Shape"/></g><g fill="#fff"><path d="M62 24v5h-2v-3h-3v-2h5zM62 20h-5v-2h3v-3h2v5zM70 20v-5h2v3h3v2h-5zM70 24h5v2h-3v3h-2v-5z"/></g><path d="M20.586 66l-5.656-5.656 1.414-1.414L22 64.586l5.656-5.656 1.414 1.414L23.414 66l5.656 5.656-1.414 1.414L22 67.414l-5.656 5.656-1.414-1.414L20.586 66z" fill="#fff"/><path d="M111.785 65.03L110 63.5l3-3.5h-10v-2h10l-3-3.5 1.785-1.468L117 59l-5.215 6.03z" fill="#fff"/><path d="M152.215 65.03L154 63.5l-3-3.5h10v-2h-10l3-3.5-1.785-1.468L147 59l5.215 6.03z" fill="#fff"/><g><path id="Rectangle-11" fill="#fff" d="M160.957 28.543l-3.25-3.25-1.413 1.414 3.25 3.25z"/><path d="M152.5 27c3.038 0 5.5-2.462 5.5-5.5s-2.462-5.5-5.5-5.5-5.5 2.462-5.5 5.5 2.462 5.5 5.5 5.5z" id="Oval-1" stroke="#fff" stroke-width="1.5"/><path fill="#fff" d="M150 21h5v1h-5z"/></g><g><path d="M116.957 28.543l-1.414 1.414-3.25-3.25 1.414-1.414 3.25 3.25z" fill="#fff"/><path d="M108.5 27c3.038 0 5.5-2.462 5.5-5.5s-2.462-5.5-5.5-5.5-5.5 2.462-5.5 5.5 2.462 5.5 5.5 5.5z" stroke="#fff" stroke-width="1.5"/><path fill="#fff" d="M106 21h5v1h-5z"/><path fill="#fff" d="M109.043 19.008l-.085 5-1-.017.085-5z"/></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -386,7 +386,7 @@ frappe.Application = class Application {
$(document.activeElement).blur();
// wait for possible JS validations triggered after blur (it might change primary button)
setTimeout(() => {
if (window.cur_dialog && cur_dialog.display) {
if (window.cur_dialog && cur_dialog.display && !cur_dialog.is_minimized) {
// trigger primary
cur_dialog.get_primary_btn().trigger("click");
} else if (cur_frm && cur_frm.page.btn_primary.is(":visible")) {

View file

@ -19,6 +19,10 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex
return; // Don't show copy button in write mode
}
if (this.copy_button) {
return;
}
this.copy_button = $(
`<button
class="btn icon-btn"

View file

@ -2131,14 +2131,33 @@ frappe.ui.form.Form = class FrappeForm {
});
});
}
set_active_tab(tab) {
if (!this.active_tab_map) {
this.active_tab_map = {};
const previous_tab_name = this.active_tab_map?.[this.docname]?.df?.fieldname || "";
const next_tab_name = tab?.df?.fieldname || "";
const has_changed = previous_tab_name !== next_tab_name;
// A change is always detected on first render, because next_tab_name is always set (= fieldname)
// but the previous_tab_name is always empty.
if (!has_changed) {
return; // No change in tab, don't trigger on_tab_change, don't update URL hash
}
this.active_tab_map ??= {};
this.active_tab_map[this.docname] = tab;
// Update URL hash to reflect the active tab
const new_hash = next_tab_name.replace("__details", "");
const url = new URL(window.location.href);
url.hash = new_hash;
if (url.href !== window.location.href) {
history.replaceState(null, null, url);
}
this.script_manager.trigger("on_tab_change");
}
get_active_tab() {
return this.active_tab_map && this.active_tab_map[this.docname];
}

View file

@ -838,7 +838,10 @@ export default class Grid {
acc[d.fieldname] = d.default;
return acc;
}, {});
this.df.data.push({ idx: this.df.data.length + 1, __islocal: true, ...defaults });
const row_idx = this.df.data.length + 1;
this.df.data.push({ idx: row_idx, __islocal: true, ...defaults });
this.df.on_add_row && this.df.on_add_row(row_idx);
this.refresh();
}

View file

@ -1442,7 +1442,9 @@ export default class GridRow {
let field = this.on_grid_fields_dict[fieldname];
// reset field value
if (field) {
field.docname = this.doc.name;
// the below if statement is added to factor in the exception when this.doc is undefined -
// - after row removals via customize_form.js on links, actions and states child-tables
if (this.doc) field.docname = this.doc.name;
field.refresh();
}

View file

@ -413,6 +413,14 @@ frappe.ui.form.Layout = class Layout {
}
set_tab_as_active() {
// Set active tab based on hash
const tab_from_hash = window.location.hash.replace("#", "");
const tab = this.tabs.find((tab) => tab.df.fieldname === tab_from_hash);
if (tab) {
tab.set_active();
return;
}
let frm_active_tab = this.frm?.get_active_tab?.();
if (frm_active_tab) {
frm_active_tab.set_active();

View file

@ -13,11 +13,11 @@ frappe.ui.get_print_settings = function (pdf, callback, letter_head, pick_column
label: __("With Letter head"),
},
{
fieldtype: "Select",
fieldtype: "Link",
fieldname: "letter_head",
label: __("Letter Head"),
depends_on: "with_letter_head",
options: Object.keys(frappe.boot.letter_heads),
options: "Letter Head",
default: letter_head || default_letter_head,
},
{

View file

@ -191,7 +191,10 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
doc: me.dialog.doc,
},
callback: function (r) {
if (frappe.model.is_submittable(me.doctype)) {
if (
frappe.model.is_submittable(me.doctype) &&
!frappe.model.has_workflow(me.doctype)
) {
frappe.run_serially([
() => (me.dialog.working = true),
() => {

View file

@ -22,7 +22,7 @@ export default class Tab {
href="#${id}"
role="tab"
aria-controls="${id}">
${__(this.label)}
${__(this.label, null, this.doctype)}
</a>
</li>
`).appendTo(this.tab_link_container);
@ -76,6 +76,7 @@ export default class Tab {
add_field(fieldobj) {
fieldobj.tab = this;
}
replace_field(fieldobj) {
fieldobj.tab = this;
}
@ -96,7 +97,7 @@ export default class Tab {
setup_listeners() {
this.tab_link.find(".nav-link").on("shown.bs.tab", () => {
this?.frm.set_active_tab?.(this);
this.frm?.set_active_tab?.(this);
});
}
}

View file

@ -77,7 +77,8 @@ frappe.ui.form.Toolbar = class Toolbar {
this.frm.perm[0].write &&
!this.frm.doc.__islocal &&
doc_field.fieldtype === "Data" &&
!doc_field.read_only
!doc_field.read_only &&
!doc_field.set_only_once
) {
return true;
} else {
@ -550,7 +551,7 @@ frappe.ui.form.Toolbar = class Toolbar {
}
if (frappe.model.can_create("DocType")) {
if (frappe.boot.developer_mode === 1 && !is_doctype_form) {
if (frappe.boot.developer_mode && !is_doctype_form) {
// edit doctype
this.page.add_menu_item(
__("Edit DocType"),

View file

@ -762,6 +762,10 @@ class FilterArea {
}
return frappe.run_serially(promises).then(() => {
this.trigger_refresh = true;
if (promises.length === 0) {
// refresh if there are no standard fields
this.debounced_refresh_list_view();
}
});
}

View file

@ -1730,7 +1730,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
shortcut: "Ctrl+K",
});
if (frappe.user.has_role("System Manager") && frappe.boot.developer_mode === 1) {
if (frappe.user.has_role("System Manager") && frappe.boot.developer_mode) {
// edit doctype
items.push({
label: __("Edit DocType", null, "Button in list view menu"),

View file

@ -15,15 +15,6 @@ frappe.route_options = null;
frappe.open_in_new_tab = false;
frappe.route_hooks = {};
$(window).on("hashchange", function (e) {
// v1 style routing, route is in hash
if (window.location.hash && !frappe.router.is_app_route(e.currentTarget.pathname)) {
let sub_path = frappe.router.get_sub_path(window.location.hash);
frappe.router.push_state(sub_path);
return false;
}
});
window.addEventListener("popstate", (e) => {
// forward-back button, just re-render based on current route
frappe.router.route();
@ -58,11 +49,6 @@ $("body").on("click", "a", function (e) {
return;
}
if (href && href.startsWith("#")) {
// target startswith "#", this is a v1 style route, so remake it.
return override(target_element.hash);
}
if (frappe.router.is_app_route(target_element.pathname)) {
// target has "/app, this is a v2 style route.
if (target_element.search) {
@ -523,13 +509,8 @@ frappe.router = {
get_sub_path_string(route) {
// return clean sub_path from hash or url
// supports both v1 and v2 routing
if (!route) {
route = window.location.pathname;
if (route.includes("app#")) {
// to support v1
route = window.location.hash;
}
}
return this.strip_prefix(route);

View file

@ -74,9 +74,9 @@ frappe.ui.LinkPreview = class {
}
this.popover_timeout = setTimeout(() => {
if (this.popover && this.popover.options) {
if (this.popover && this.popover.config) {
let new_content = this.get_popover_html(preview_data);
this.popover.options.content = new_content;
this.popover.config.content = new_content;
} else {
this.init_preview_popover(preview_data);
}

View file

@ -1339,10 +1339,19 @@ Object.assign(frappe.utils, {
// return number if total digits is lesser than min_length
const len = String(number).match(/\d/g).length;
if (len < min_length) return number.toString();
if (len < min_length) {
return number.toString();
}
const number_system = this.get_number_system(country);
let x = Math.abs(Math.round(number));
// if rounding was sufficient to get below min_length, return the rounded number
const x_string = x.toString();
if (x_string.length < min_length) {
return x_string;
}
for (const map of number_system) {
if (x >= map.divisor) {
let result = number / map.divisor;

View file

@ -40,14 +40,16 @@ frappe.views.ImageView = class ImageView extends frappe.views.ListView {
}
render() {
this.get_attached_images().then(() => {
this.render_image_view();
this.load_lib.then(() => {
this.get_attached_images().then(() => {
this.render_image_view();
if (!this.gallery) {
this.setup_gallery();
} else {
this.gallery.prepare_pswp_items(this.items, this.images_map);
}
if (!this.gallery) {
this.setup_gallery();
} else {
this.gallery.prepare_pswp_items(this.items, this.images_map);
}
});
});
}
@ -155,24 +157,6 @@ frappe.views.ImageView = class ImageView extends frappe.views.ListView {
});
}
get_header_html() {
// return this.get_header_html_skeleton(`
// <div class="list-image-header">
// <div class="list-image-header-item">
// <input class="level-item list-check-all hidden-xs" type="checkbox" title="Select All">
// <div>${__(this.doctype)} &nbsp;</div>
// (<span class="text-muted list-count"></span>)
// </div>
// <div class="list-image-header-item">
// <div class="level-item list-liked-by-me">
// ${frappe.utils.icon('heart', 'sm', 'like-icon')}
// </div>
// <div>${__('Liked')}</div>
// </div>
// </div>
// `);
}
setup_gallery() {
var me = this;
this.gallery = new frappe.views.GalleryView({
@ -190,17 +174,20 @@ frappe.views.ImageView = class ImageView extends frappe.views.ListView {
return false;
});
}
get required_libs() {
return [
"assets/frappe/node_modules/photoswipe/src/photoswipe.css",
"photoswipe.bundle.js",
];
}
};
frappe.views.GalleryView = class GalleryView {
constructor(opts) {
$.extend(this, opts);
var me = this;
this.lib_ready = this.load_lib();
this.lib_ready.then(function () {
me.prepare();
});
me.prepare();
}
prepare() {
// keep only one pswp dom element
@ -220,146 +207,51 @@ frappe.views.GalleryView = class GalleryView {
}
return new Promise((resolve) => {
const items = this.items.map(function (i) {
const query = 'img[data-name="' + i._name + '"]';
let el = me.wrapper.find(query).get(0);
const items = this.items
.filter((i) => i.image !== null)
.map(function (i) {
const query = 'img[data-name="' + i._name + '"]';
let el = me.wrapper.find(query).get(0);
let width, height;
if (el) {
width = el.naturalWidth;
height = el.naturalHeight;
}
let width, height;
if (el) {
width = el.naturalWidth;
height = el.naturalHeight;
}
if (!el) {
el = me.wrapper.find('.image-field[data-name="' + i._name + '"]').get(0);
width = el.getBoundingClientRect().width;
height = el.getBoundingClientRect().height;
}
if (!el) {
el = me.wrapper.find('.image-field[data-name="' + i._name + '"]').get(0);
width = el.getBoundingClientRect().width;
height = el.getBoundingClientRect().height;
}
return {
src: i._image_url,
msrc: i._image_url,
name: i.name,
w: width,
h: height,
el: el,
};
});
return {
src: i._image_url,
name: i.name,
width: width,
height: height,
};
});
this.pswp_items = items;
resolve();
});
}
show(docname) {
this.lib_ready.then(() => this.prepare_pswp_items()).then(() => this._show(docname));
this.prepare_pswp_items().then(() => this._show(docname));
}
_show(docname) {
const me = this;
const items = this.pswp_items;
const item_index = items.findIndex((item) => item.name === docname);
var options = {
index: item_index,
getThumbBoundsFn: function (index) {
const query = 'img[data-name="' + items[index]._name + '"]';
let thumbnail = me.wrapper.find(query).get(0);
if (!thumbnail) {
return;
}
var pageYScroll = window.pageYOffset || document.documentElement.scrollTop,
rect = thumbnail.getBoundingClientRect();
return {
x: rect.left,
y: rect.top + pageYScroll,
w: rect.width,
};
},
history: false,
shareEl: false,
showHideOpacity: true,
dataSource: items,
};
// init
this.pswp = new PhotoSwipe(this.pswp_root.get(0), PhotoSwipeUI_Default, items, options);
this.browse_images();
this.pswp = new frappe.PhotoSwipe(options);
this.pswp.init();
}
browse_images() {
const $more_items = this.pswp_root.find(".pswp__more-items");
const images_map = this.images_map;
let last_hide_timeout = null;
this.pswp.listen("afterChange", function () {
const images = images_map[this.currItem.name];
if (!images || images.length === 1) {
$more_items.html("");
return;
}
hide_more_items_after_2s();
const html = images.map(img_html).join("");
$more_items.html(html);
});
this.pswp.listen("beforeChange", hide_more_items);
this.pswp.listen("initialZoomOut", hide_more_items);
this.pswp.listen("destroy", () => {
$(document).off("mousemove", hide_more_items_after_2s);
});
// Replace current image on click
$more_items.on("click", ".pswp__more-item", (e) => {
const img_el = e.target;
const index = this.pswp.items.findIndex((i) => i.name === this.pswp.currItem.name);
this.pswp.goTo(index);
this.pswp.items.splice(index, 1, {
src: img_el.src,
w: img_el.naturalWidth,
h: img_el.naturalHeight,
name: this.pswp.currItem.name,
});
this.pswp.invalidateCurrItems();
this.pswp.updateSize(true);
});
// hide more-images 2s after mousemove
$(document).on("mousemove", hide_more_items_after_2s);
function hide_more_items_after_2s() {
clearTimeout(last_hide_timeout);
show_more_items();
last_hide_timeout = setTimeout(hide_more_items, 2000);
}
function show_more_items() {
$more_items.show();
}
function hide_more_items() {
$more_items.hide();
}
function img_html(src) {
return `<div class="pswp__more-item">
<img src="${src}">
</div>`;
}
}
load_lib() {
return new Promise((resolve) => {
var asset_dir = "assets/frappe/js/lib/photoswipe/";
frappe.require(
[
asset_dir + "photoswipe.css",
asset_dir + "default-skin.css",
asset_dir + "photoswipe.js",
asset_dir + "photoswipe-ui-default.js",
],
resolve
);
});
}
};

View file

@ -53,7 +53,6 @@ frappe.provide("frappe.views");
var state = context.state;
var _cards = cards
.map((card) => prepare_card(card, state))
.concat(state.cards)
.uniqBy((card) => card.name);
context.commit("update_state", {
@ -303,7 +302,7 @@ frappe.provide("frappe.views");
// update cards internally
opts.cards = cards;
if (self.wrapper.find(".kanban").length > 0 && self.cur_list.start !== 0) {
if (self.wrapper.find(".kanban").length > 0) {
store.dispatch("update_cards", cards);
} else {
init();

View file

@ -1,4 +1,4 @@
<div class="kanban-column" data-column-value="{{title}}">
<div class="kanban-column" data-column-value="{{title}}" style="background-color: var(--bg-{{indicator}});">
<div class="kanban-column-header">
<span class="kanban-column-title">
<span class="indicator-pill {{indicator}}"></span>

View file

@ -136,10 +136,13 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
this.hide_card_layout = true;
this.hide_sort_selector = true;
super.setup_page();
this.page.disable_sidebar_toggle = true;
this.page.setup_sidebar_toggle();
}
setup_view() {
if (this.board.columns.length > 5) {
if (this.board.columns.filter((col) => col.status !== "Archived").length > 4) {
this.page.container.addClass("full-width");
}
this.setup_realtime_updates();

View file

@ -68,6 +68,7 @@ frappe.views.Workspace = class Workspace {
this.cached_pages = $.extend(true, {}, this.sidebar_pages);
this.all_pages = this.sidebar_pages.pages;
this.has_access = this.sidebar_pages.has_access;
this.has_create_access = this.sidebar_pages.has_create_access;
this.all_pages.forEach((page) => {
page.is_editable = !page.public || this.has_access;
@ -473,9 +474,10 @@ frappe.views.Workspace = class Workspace {
"es-line-edit"
);
// need to add option for icons in inner buttons as well
this.page.add_inner_button(__("Create Workspace"), () => {
this.initialize_new_page();
});
if (this.has_create_access)
this.page.add_inner_button(__("Create Workspace"), () => {
this.initialize_new_page(true);
});
}
initialize_editorjs_undo() {

View file

@ -223,9 +223,9 @@ export default class NumberCardWidget extends Widget {
const symbol = number_parts[1] || "";
number_parts[0] = window.convert_old_to_new_number_format(number_parts[0]);
const formatted_number = $(frappe.format(number_parts[0], df, null, doc)).text();
this.formatted_number = formatted_number + " " + __(symbol);
const formatted_number = frappe.format(number_parts[0], df, null, doc);
this.formatted_number =
($(formatted_number).text() || formatted_number) + " " + __(symbol);
}
_generate_common_doc(rows) {

View file

@ -178,11 +178,6 @@ export default class OnboardingWidget extends Widget {
actions[step.action](step);
});
// Fire only once, on hashchange
$(window).one("hashchange", () => {
plyr.pause();
});
$(`<button class="btn btn-secondary ml-2 btn-sm">${__("Back")}</button>`)
.appendTo(this.step_footer)
.on("click", toggle_content);

View file

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2014-2015 Dmitry Semenov, http://dimsemenov.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,483 +0,0 @@
/*! PhotoSwipe Default UI CSS by Dmitry Semenov | photoswipe.com | MIT license */
/*
Contents:
1. Buttons
2. Share modal and links
3. Index indicator ("1 of X" counter)
4. Caption
5. Loading indicator
6. Additional styles (root element, top bar, idle state, hidden state, etc.)
*/
/*
1. Buttons
*/
/* <button> css reset */
.pswp__button {
width: 44px;
height: 44px;
position: relative;
background: none;
cursor: pointer;
overflow: visible;
-webkit-appearance: none;
display: block;
border: 0;
padding: 0;
margin: 0;
float: right;
opacity: 0.75;
-webkit-transition: opacity 0.2s;
transition: opacity 0.2s;
-webkit-box-shadow: none;
box-shadow: none; }
.pswp__button:focus,
.pswp__button:hover {
opacity: 1; }
.pswp__button:active {
outline: none;
opacity: 0.9; }
.pswp__button::-moz-focus-inner {
padding: 0;
border: 0; }
/* pswp__ui--over-close class it added when mouse is over element that should close gallery */
.pswp__ui--over-close .pswp__button--close {
opacity: 1; }
.pswp__button,
.pswp__button--arrow--left:before,
.pswp__button--arrow--right:before {
background: url(default-skin.png) 0 0 no-repeat;
background-size: 264px 88px;
width: 44px;
height: 44px; }
@media (-webkit-min-device-pixel-ratio: 1.1), (-webkit-min-device-pixel-ratio: 1.09375), (min-resolution: 105dpi), (min-resolution: 1.1dppx) {
/* Serve SVG sprite if browser supports SVG and resolution is more than 105dpi */
.pswp--svg .pswp__button,
.pswp--svg .pswp__button--arrow--left:before,
.pswp--svg .pswp__button--arrow--right:before {
background-image: url(default-skin.svg); }
.pswp--svg .pswp__button--arrow--left,
.pswp--svg .pswp__button--arrow--right {
background: none; } }
.pswp__button--close {
background-position: 0 -44px; }
.pswp__button--share {
background-position: -44px -44px; }
.pswp__button--fs {
display: none; }
.pswp--supports-fs .pswp__button--fs {
display: block; }
.pswp--fs .pswp__button--fs {
background-position: -44px 0; }
.pswp__button--zoom {
display: none;
background-position: -88px 0; }
.pswp--zoom-allowed .pswp__button--zoom {
display: block; }
.pswp--zoomed-in .pswp__button--zoom {
background-position: -132px 0; }
/* no arrows on touch screens */
.pswp--touch .pswp__button--arrow--left,
.pswp--touch .pswp__button--arrow--right {
visibility: hidden; }
/*
Arrow buttons hit area
(icon is added to :before pseudo-element)
*/
.pswp__button--arrow--left,
.pswp__button--arrow--right {
background: none;
top: 50%;
margin-top: -50px;
width: 70px;
height: 100px;
position: absolute; }
.pswp__button--arrow--left {
left: 0; }
.pswp__button--arrow--right {
right: 0; }
.pswp__button--arrow--left:before,
.pswp__button--arrow--right:before {
content: '';
top: 35px;
background-color: rgba(0, 0, 0, 0.3);
height: 30px;
width: 32px;
position: absolute; }
.pswp__button--arrow--left:before {
left: 6px;
background-position: -138px -44px; }
.pswp__button--arrow--right:before {
right: 6px;
background-position: -94px -44px; }
/*
2. Share modal/popup and links
*/
.pswp__counter,
.pswp__share-modal {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none; }
.pswp__share-modal {
display: block;
background: rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
top: 0;
left: 0;
padding: 10px;
position: absolute;
z-index: 1600;
opacity: 0;
-webkit-transition: opacity 0.25s ease-out;
transition: opacity 0.25s ease-out;
-webkit-backface-visibility: hidden;
will-change: opacity; }
.pswp__share-modal--hidden {
display: none; }
.pswp__share-tooltip {
z-index: 1620;
position: absolute;
background: #FFF;
top: 56px;
border-radius: 2px;
display: block;
width: auto;
right: 44px;
-webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25);
-webkit-transform: translateY(6px);
-ms-transform: translateY(6px);
transform: translateY(6px);
-webkit-transition: -webkit-transform 0.25s;
transition: transform 0.25s;
-webkit-backface-visibility: hidden;
will-change: transform; }
.pswp__share-tooltip a {
display: block;
padding: 8px 12px;
color: #000;
text-decoration: none;
font-size: 14px;
line-height: 18px; }
.pswp__share-tooltip a:hover {
text-decoration: none;
color: #000; }
.pswp__share-tooltip a:first-child {
/* round corners on the first/last list item */
border-radius: 2px 2px 0 0; }
.pswp__share-tooltip a:last-child {
border-radius: 0 0 2px 2px; }
.pswp__share-modal--fade-in {
opacity: 1; }
.pswp__share-modal--fade-in .pswp__share-tooltip {
-webkit-transform: translateY(0);
-ms-transform: translateY(0);
transform: translateY(0); }
/* increase size of share links on touch devices */
.pswp--touch .pswp__share-tooltip a {
padding: 16px 12px; }
a.pswp__share--facebook:before {
content: '';
display: block;
width: 0;
height: 0;
position: absolute;
top: -12px;
right: 15px;
border: 6px solid transparent;
border-bottom-color: #FFF;
-webkit-pointer-events: none;
-moz-pointer-events: none;
pointer-events: none; }
a.pswp__share--facebook:hover {
background: #3E5C9A;
color: #FFF; }
a.pswp__share--facebook:hover:before {
border-bottom-color: #3E5C9A; }
a.pswp__share--twitter:hover {
background: #55ACEE;
color: #FFF; }
a.pswp__share--pinterest:hover {
background: #CCC;
color: #CE272D; }
a.pswp__share--download:hover {
background: #DDD; }
/*
3. Index indicator ("1 of X" counter)
*/
.pswp__counter {
position: absolute;
left: 0;
top: 0;
height: 44px;
font-size: 13px;
line-height: 44px;
color: #FFF;
opacity: 0.75;
padding: 0 10px; }
/*
4. Caption
*/
.pswp__caption {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
min-height: 44px; }
.pswp__caption small {
font-size: 11px;
color: #BBB; }
.pswp__caption__center {
text-align: left;
max-width: 420px;
margin: 0 auto;
font-size: 13px;
padding: 10px;
line-height: 20px;
color: #CCC; }
.pswp__caption--empty {
display: none; }
/* Fake caption element, used to calculate height of next/prev image */
.pswp__caption--fake {
visibility: hidden; }
/*
5. Loading indicator (preloader)
You can play with it here - http://codepen.io/dimsemenov/pen/yyBWoR
*/
.pswp__preloader {
width: 44px;
height: 44px;
position: absolute;
top: 0;
left: 50%;
margin-left: -22px;
opacity: 0;
-webkit-transition: opacity 0.25s ease-out;
transition: opacity 0.25s ease-out;
will-change: opacity;
direction: ltr; }
.pswp__preloader__icn {
width: 20px;
height: 20px;
margin: 12px; }
.pswp__preloader--active {
opacity: 1; }
.pswp__preloader--active .pswp__preloader__icn {
/* We use .gif in browsers that don't support CSS animation */
background: url(preloader.gif) 0 0 no-repeat; }
.pswp--css_animation .pswp__preloader--active {
opacity: 1; }
.pswp--css_animation .pswp__preloader--active .pswp__preloader__icn {
-webkit-animation: clockwise 500ms linear infinite;
animation: clockwise 500ms linear infinite; }
.pswp--css_animation .pswp__preloader--active .pswp__preloader__donut {
-webkit-animation: donut-rotate 1000ms cubic-bezier(0.4, 0, 0.22, 1) infinite;
animation: donut-rotate 1000ms cubic-bezier(0.4, 0, 0.22, 1) infinite; }
.pswp--css_animation .pswp__preloader__icn {
background: none;
opacity: 0.75;
width: 14px;
height: 14px;
position: absolute;
left: 15px;
top: 15px;
margin: 0; }
.pswp--css_animation .pswp__preloader__cut {
/*
The idea of animating inner circle is based on Polymer ("material") loading indicator
by Keanu Lee https://blog.keanulee.com/2014/10/20/the-tale-of-three-spinners.html
*/
position: relative;
width: 7px;
height: 14px;
overflow: hidden; }
.pswp--css_animation .pswp__preloader__donut {
-webkit-box-sizing: border-box;
box-sizing: border-box;
width: 14px;
height: 14px;
border: 2px solid #FFF;
border-radius: 50%;
border-left-color: transparent;
border-bottom-color: transparent;
position: absolute;
top: 0;
left: 0;
background: none;
margin: 0; }
@media screen and (max-width: 1024px) {
.pswp__preloader {
position: relative;
left: auto;
top: auto;
margin: 0;
float: right; } }
@-webkit-keyframes clockwise {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg); }
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg); } }
@keyframes clockwise {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg); }
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg); } }
@-webkit-keyframes donut-rotate {
0% {
-webkit-transform: rotate(0);
transform: rotate(0); }
50% {
-webkit-transform: rotate(-140deg);
transform: rotate(-140deg); }
100% {
-webkit-transform: rotate(0);
transform: rotate(0); } }
@keyframes donut-rotate {
0% {
-webkit-transform: rotate(0);
transform: rotate(0); }
50% {
-webkit-transform: rotate(-140deg);
transform: rotate(-140deg); }
100% {
-webkit-transform: rotate(0);
transform: rotate(0); } }
/*
6. Additional styles
*/
/* root element of UI */
.pswp__ui {
-webkit-font-smoothing: auto;
visibility: visible;
opacity: 1;
z-index: 1550; }
/* top black bar with buttons and "1 of X" indicator */
.pswp__top-bar {
position: absolute;
left: 0;
top: 0;
height: 44px;
width: 100%; }
.pswp__caption,
.pswp__top-bar,
.pswp--has_mouse .pswp__button--arrow--left,
.pswp--has_mouse .pswp__button--arrow--right {
-webkit-backface-visibility: hidden;
will-change: opacity;
-webkit-transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1);
transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1); }
/* pswp--has_mouse class is added only when two subsequent mousemove events occur */
.pswp--has_mouse .pswp__button--arrow--left,
.pswp--has_mouse .pswp__button--arrow--right {
visibility: visible; }
.pswp__top-bar,
.pswp__caption {
background-color: rgba(0, 0, 0, 0.5); }
/* pswp__ui--fit class is added when main image "fits" between top bar and bottom bar (caption) */
.pswp__ui--fit .pswp__top-bar,
.pswp__ui--fit .pswp__caption {
background-color: rgba(0, 0, 0, 0.3); }
/* pswp__ui--idle class is added when mouse isn't moving for several seconds (JS option timeToIdle) */
.pswp__ui--idle .pswp__top-bar {
opacity: 0; }
.pswp__ui--idle .pswp__button--arrow--left,
.pswp__ui--idle .pswp__button--arrow--right {
opacity: 0; }
/*
pswp__ui--hidden class is added when controls are hidden
e.g. when user taps to toggle visibility of controls
*/
.pswp__ui--hidden .pswp__top-bar,
.pswp__ui--hidden .pswp__caption,
.pswp__ui--hidden .pswp__button--arrow--left,
.pswp__ui--hidden .pswp__button--arrow--right {
/* Force paint & create composition layer for controls. */
opacity: 0.001; }
/* pswp__ui--one-slide class is added when there is just one item in gallery */
.pswp__ui--one-slide .pswp__button--arrow--left,
.pswp__ui--one-slide .pswp__button--arrow--right,
.pswp__ui--one-slide .pswp__counter {
display: none; }
.pswp__element--disabled {
display: none !important; }
.pswp--minimal--dark .pswp__top-bar {
background: none; }

View file

@ -1,861 +0,0 @@
/*! PhotoSwipe Default UI - 4.1.1 - 2015-12-24
* http://photoswipe.com
* Copyright (c) 2015 Dmitry Semenov; */
/**
*
* UI on top of main sliding area (caption, arrows, close button, etc.).
* Built just using public methods/properties of PhotoSwipe.
*
*/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define(factory);
} else if (typeof exports === 'object') {
module.exports = factory();
} else {
root.PhotoSwipeUI_Default = factory();
}
})(this, function () {
'use strict';
var PhotoSwipeUI_Default =
function(pswp, framework) {
var ui = this;
var _overlayUIUpdated = false,
_controlsVisible = true,
_fullscrenAPI,
_controls,
_captionContainer,
_fakeCaptionContainer,
_indexIndicator,
_shareButton,
_shareModal,
_shareModalHidden = true,
_initalCloseOnScrollValue,
_isIdle,
_listen,
_loadingIndicator,
_loadingIndicatorHidden,
_loadingIndicatorTimeout,
_galleryHasOneSlide,
_options,
_defaultUIOptions = {
barsSize: {top:44, bottom:'auto'},
closeElClasses: ['item', 'caption', 'zoom-wrap', 'ui', 'top-bar'],
timeToIdle: 4000,
timeToIdleOutside: 1000,
loadingIndicatorDelay: 1000, // 2s
addCaptionHTMLFn: function(item, captionEl /*, isFake */) {
if(!item.title) {
captionEl.children[0].innerHTML = '';
return false;
}
captionEl.children[0].innerHTML = item.title;
return true;
},
closeEl:true,
captionEl: true,
fullscreenEl: true,
zoomEl: true,
shareEl: true,
counterEl: true,
arrowEl: true,
preloaderEl: true,
tapToClose: false,
tapToToggleControls: true,
clickToCloseNonZoomable: true,
shareButtons: [
{id:'facebook', label:'Share on Facebook', url:'https://www.facebook.com/sharer/sharer.php?u={{url}}'},
{id:'twitter', label:'Tweet', url:'https://twitter.com/intent/tweet?text={{text}}&url={{url}}'},
{id:'pinterest', label:'Pin it', url:'http://www.pinterest.com/pin/create/button/'+
'?url={{url}}&media={{image_url}}&description={{text}}'},
{id:'download', label:'Download image', url:'{{raw_image_url}}', download:true}
],
getImageURLForShare: function( /* shareButtonData */ ) {
return pswp.currItem.src || '';
},
getPageURLForShare: function( /* shareButtonData */ ) {
return window.location.href;
},
getTextForShare: function( /* shareButtonData */ ) {
return pswp.currItem.title || '';
},
indexIndicatorSep: ' / ',
fitControlsWidth: 1200
},
_blockControlsTap,
_blockControlsTapTimeout;
var _onControlsTap = function(e) {
if(_blockControlsTap) {
return true;
}
e = e || window.event;
if(_options.timeToIdle && _options.mouseUsed && !_isIdle) {
// reset idle timer
_onIdleMouseMove();
}
var target = e.target || e.srcElement,
uiElement,
clickedClass = target.getAttribute('class') || '',
found;
for(var i = 0; i < _uiElements.length; i++) {
uiElement = _uiElements[i];
if(uiElement.onTap && clickedClass.indexOf('pswp__' + uiElement.name ) > -1 ) {
uiElement.onTap();
found = true;
}
}
if(found) {
if(e.stopPropagation) {
e.stopPropagation();
}
_blockControlsTap = true;
// Some versions of Android don't prevent ghost click event
// when preventDefault() was called on touchstart and/or touchend.
//
// This happens on v4.3, 4.2, 4.1,
// older versions strangely work correctly,
// but just in case we add delay on all of them)
var tapDelay = framework.features.isOldAndroid ? 600 : 30;
_blockControlsTapTimeout = setTimeout(function() {
_blockControlsTap = false;
}, tapDelay);
}
},
_fitControlsInViewport = function() {
return !pswp.likelyTouchDevice || _options.mouseUsed || screen.width > _options.fitControlsWidth;
},
_togglePswpClass = function(el, cName, add) {
framework[ (add ? 'add' : 'remove') + 'Class' ](el, 'pswp__' + cName);
},
// add class when there is just one item in the gallery
// (by default it hides left/right arrows and 1ofX counter)
_countNumItems = function() {
var hasOneSlide = (_options.getNumItemsFn() === 1);
if(hasOneSlide !== _galleryHasOneSlide) {
_togglePswpClass(_controls, 'ui--one-slide', hasOneSlide);
_galleryHasOneSlide = hasOneSlide;
}
},
_toggleShareModalClass = function() {
_togglePswpClass(_shareModal, 'share-modal--hidden', _shareModalHidden);
},
_toggleShareModal = function() {
_shareModalHidden = !_shareModalHidden;
if(!_shareModalHidden) {
_toggleShareModalClass();
setTimeout(function() {
if(!_shareModalHidden) {
framework.addClass(_shareModal, 'pswp__share-modal--fade-in');
}
}, 30);
} else {
framework.removeClass(_shareModal, 'pswp__share-modal--fade-in');
setTimeout(function() {
if(_shareModalHidden) {
_toggleShareModalClass();
}
}, 300);
}
if(!_shareModalHidden) {
_updateShareURLs();
}
return false;
},
_openWindowPopup = function(e) {
e = e || window.event;
var target = e.target || e.srcElement;
pswp.shout('shareLinkClick', e, target);
if(!target.href) {
return false;
}
if( target.hasAttribute('download') ) {
return true;
}
window.open(target.href, 'pswp_share', 'scrollbars=yes,resizable=yes,toolbar=no,'+
'location=yes,width=550,height=420,top=100,left=' +
(window.screen ? Math.round(screen.width / 2 - 275) : 100) );
if(!_shareModalHidden) {
_toggleShareModal();
}
return false;
},
_updateShareURLs = function() {
var shareButtonOut = '',
shareButtonData,
shareURL,
image_url,
page_url,
share_text;
for(var i = 0; i < _options.shareButtons.length; i++) {
shareButtonData = _options.shareButtons[i];
image_url = _options.getImageURLForShare(shareButtonData);
page_url = _options.getPageURLForShare(shareButtonData);
share_text = _options.getTextForShare(shareButtonData);
shareURL = shareButtonData.url.replace('{{url}}', encodeURIComponent(page_url) )
.replace('{{image_url}}', encodeURIComponent(image_url) )
.replace('{{raw_image_url}}', image_url )
.replace('{{text}}', encodeURIComponent(share_text) );
shareButtonOut += '<a href="' + shareURL + '" target="_blank" '+
'class="pswp__share--' + shareButtonData.id + '"' +
(shareButtonData.download ? 'download' : '') + '>' +
shareButtonData.label + '</a>';
if(_options.parseShareButtonOut) {
shareButtonOut = _options.parseShareButtonOut(shareButtonData, shareButtonOut);
}
}
_shareModal.children[0].innerHTML = shareButtonOut;
_shareModal.children[0].onclick = _openWindowPopup;
},
_hasCloseClass = function(target) {
for(var i = 0; i < _options.closeElClasses.length; i++) {
if( framework.hasClass(target, 'pswp__' + _options.closeElClasses[i]) ) {
return true;
}
}
},
_idleInterval,
_idleTimer,
_idleIncrement = 0,
_onIdleMouseMove = function() {
clearTimeout(_idleTimer);
_idleIncrement = 0;
if(_isIdle) {
ui.setIdle(false);
}
},
_onMouseLeaveWindow = function(e) {
e = e ? e : window.event;
var from = e.relatedTarget || e.toElement;
if (!from || from.nodeName === 'HTML') {
clearTimeout(_idleTimer);
_idleTimer = setTimeout(function() {
ui.setIdle(true);
}, _options.timeToIdleOutside);
}
},
_setupFullscreenAPI = function() {
if(_options.fullscreenEl && !framework.features.isOldAndroid) {
if(!_fullscrenAPI) {
_fullscrenAPI = ui.getFullscreenAPI();
}
if(_fullscrenAPI) {
framework.bind(document, _fullscrenAPI.eventK, ui.updateFullscreen);
ui.updateFullscreen();
framework.addClass(pswp.template, 'pswp--supports-fs');
} else {
framework.removeClass(pswp.template, 'pswp--supports-fs');
}
}
},
_setupLoadingIndicator = function() {
// Setup loading indicator
if(_options.preloaderEl) {
_toggleLoadingIndicator(true);
_listen('beforeChange', function() {
clearTimeout(_loadingIndicatorTimeout);
// display loading indicator with delay
_loadingIndicatorTimeout = setTimeout(function() {
if(pswp.currItem && pswp.currItem.loading) {
if( !pswp.allowProgressiveImg() || (pswp.currItem.img && !pswp.currItem.img.naturalWidth) ) {
// show preloader if progressive loading is not enabled,
// or image width is not defined yet (because of slow connection)
_toggleLoadingIndicator(false);
// items-controller.js function allowProgressiveImg
}
} else {
_toggleLoadingIndicator(true); // hide preloader
}
}, _options.loadingIndicatorDelay);
});
_listen('imageLoadComplete', function(index, item) {
if(pswp.currItem === item) {
_toggleLoadingIndicator(true);
}
});
}
},
_toggleLoadingIndicator = function(hide) {
if( _loadingIndicatorHidden !== hide ) {
_togglePswpClass(_loadingIndicator, 'preloader--active', !hide);
_loadingIndicatorHidden = hide;
}
},
_applyNavBarGaps = function(item) {
var gap = item.vGap;
if( _fitControlsInViewport() ) {
var bars = _options.barsSize;
if(_options.captionEl && bars.bottom === 'auto') {
if(!_fakeCaptionContainer) {
_fakeCaptionContainer = framework.createEl('pswp__caption pswp__caption--fake');
_fakeCaptionContainer.appendChild( framework.createEl('pswp__caption__center') );
_controls.insertBefore(_fakeCaptionContainer, _captionContainer);
framework.addClass(_controls, 'pswp__ui--fit');
}
if( _options.addCaptionHTMLFn(item, _fakeCaptionContainer, true) ) {
var captionSize = _fakeCaptionContainer.clientHeight;
gap.bottom = parseInt(captionSize,10) || 44;
} else {
gap.bottom = bars.top; // if no caption, set size of bottom gap to size of top
}
} else {
gap.bottom = bars.bottom === 'auto' ? 0 : bars.bottom;
}
// height of top bar is static, no need to calculate it
gap.top = bars.top;
} else {
gap.top = gap.bottom = 0;
}
},
_setupIdle = function() {
// Hide controls when mouse is used
if(_options.timeToIdle) {
_listen('mouseUsed', function() {
framework.bind(document, 'mousemove', _onIdleMouseMove);
framework.bind(document, 'mouseout', _onMouseLeaveWindow);
_idleInterval = setInterval(function() {
_idleIncrement++;
if(_idleIncrement === 2) {
ui.setIdle(true);
}
}, _options.timeToIdle / 2);
});
}
},
_setupHidingControlsDuringGestures = function() {
// Hide controls on vertical drag
_listen('onVerticalDrag', function(now) {
if(_controlsVisible && now < 0.95) {
ui.hideControls();
} else if(!_controlsVisible && now >= 0.95) {
ui.showControls();
}
});
// Hide controls when pinching to close
var pinchControlsHidden;
_listen('onPinchClose' , function(now) {
if(_controlsVisible && now < 0.9) {
ui.hideControls();
pinchControlsHidden = true;
} else if(pinchControlsHidden && !_controlsVisible && now > 0.9) {
ui.showControls();
}
});
_listen('zoomGestureEnded', function() {
pinchControlsHidden = false;
if(pinchControlsHidden && !_controlsVisible) {
ui.showControls();
}
});
};
var _uiElements = [
{
name: 'caption',
option: 'captionEl',
onInit: function(el) {
_captionContainer = el;
}
},
{
name: 'share-modal',
option: 'shareEl',
onInit: function(el) {
_shareModal = el;
},
onTap: function() {
_toggleShareModal();
}
},
{
name: 'button--share',
option: 'shareEl',
onInit: function(el) {
_shareButton = el;
},
onTap: function() {
_toggleShareModal();
}
},
{
name: 'button--zoom',
option: 'zoomEl',
onTap: pswp.toggleDesktopZoom
},
{
name: 'counter',
option: 'counterEl',
onInit: function(el) {
_indexIndicator = el;
}
},
{
name: 'button--close',
option: 'closeEl',
onTap: pswp.close
},
{
name: 'button--arrow--left',
option: 'arrowEl',
onTap: pswp.prev
},
{
name: 'button--arrow--right',
option: 'arrowEl',
onTap: pswp.next
},
{
name: 'button--fs',
option: 'fullscreenEl',
onTap: function() {
if(_fullscrenAPI.isFullscreen()) {
_fullscrenAPI.exit();
} else {
_fullscrenAPI.enter();
}
}
},
{
name: 'preloader',
option: 'preloaderEl',
onInit: function(el) {
_loadingIndicator = el;
}
}
];
var _setupUIElements = function() {
var item,
classAttr,
uiElement;
var loopThroughChildElements = function(sChildren) {
if(!sChildren) {
return;
}
var l = sChildren.length;
for(var i = 0; i < l; i++) {
item = sChildren[i];
classAttr = item.className;
for(var a = 0; a < _uiElements.length; a++) {
uiElement = _uiElements[a];
if(classAttr.indexOf('pswp__' + uiElement.name) > -1 ) {
if( _options[uiElement.option] ) { // if element is not disabled from options
framework.removeClass(item, 'pswp__element--disabled');
if(uiElement.onInit) {
uiElement.onInit(item);
}
//item.style.display = 'block';
} else {
framework.addClass(item, 'pswp__element--disabled');
//item.style.display = 'none';
}
}
}
}
};
loopThroughChildElements(_controls.children);
var topBar = framework.getChildByClass(_controls, 'pswp__top-bar');
if(topBar) {
loopThroughChildElements( topBar.children );
}
};
ui.init = function() {
// extend options
framework.extend(pswp.options, _defaultUIOptions, true);
// create local link for fast access
_options = pswp.options;
// find pswp__ui element
_controls = framework.getChildByClass(pswp.scrollWrap, 'pswp__ui');
// create local link
_listen = pswp.listen;
_setupHidingControlsDuringGestures();
// update controls when slides change
_listen('beforeChange', ui.update);
// toggle zoom on double-tap
_listen('doubleTap', function(point) {
var initialZoomLevel = pswp.currItem.initialZoomLevel;
if(pswp.getZoomLevel() !== initialZoomLevel) {
pswp.zoomTo(initialZoomLevel, point, 333);
} else {
pswp.zoomTo(_options.getDoubleTapZoom(false, pswp.currItem), point, 333);
}
});
// Allow text selection in caption
_listen('preventDragEvent', function(e, isDown, preventObj) {
var t = e.target || e.srcElement;
if(
t &&
t.getAttribute('class') && e.type.indexOf('mouse') > -1 &&
( t.getAttribute('class').indexOf('__caption') > 0 || (/(SMALL|STRONG|EM)/i).test(t.tagName) )
) {
preventObj.prevent = false;
}
});
// bind events for UI
_listen('bindEvents', function() {
framework.bind(_controls, 'pswpTap click', _onControlsTap);
framework.bind(pswp.scrollWrap, 'pswpTap', ui.onGlobalTap);
if(!pswp.likelyTouchDevice) {
framework.bind(pswp.scrollWrap, 'mouseover', ui.onMouseOver);
}
});
// unbind events for UI
_listen('unbindEvents', function() {
if(!_shareModalHidden) {
_toggleShareModal();
}
if(_idleInterval) {
clearInterval(_idleInterval);
}
framework.unbind(document, 'mouseout', _onMouseLeaveWindow);
framework.unbind(document, 'mousemove', _onIdleMouseMove);
framework.unbind(_controls, 'pswpTap click', _onControlsTap);
framework.unbind(pswp.scrollWrap, 'pswpTap', ui.onGlobalTap);
framework.unbind(pswp.scrollWrap, 'mouseover', ui.onMouseOver);
if(_fullscrenAPI) {
framework.unbind(document, _fullscrenAPI.eventK, ui.updateFullscreen);
if(_fullscrenAPI.isFullscreen()) {
_options.hideAnimationDuration = 0;
_fullscrenAPI.exit();
}
_fullscrenAPI = null;
}
});
// clean up things when gallery is destroyed
_listen('destroy', function() {
if(_options.captionEl) {
if(_fakeCaptionContainer) {
_controls.removeChild(_fakeCaptionContainer);
}
framework.removeClass(_captionContainer, 'pswp__caption--empty');
}
if(_shareModal) {
_shareModal.children[0].onclick = null;
}
framework.removeClass(_controls, 'pswp__ui--over-close');
framework.addClass( _controls, 'pswp__ui--hidden');
ui.setIdle(false);
});
if(!_options.showAnimationDuration) {
framework.removeClass( _controls, 'pswp__ui--hidden');
}
_listen('initialZoomIn', function() {
if(_options.showAnimationDuration) {
framework.removeClass( _controls, 'pswp__ui--hidden');
}
});
_listen('initialZoomOut', function() {
framework.addClass( _controls, 'pswp__ui--hidden');
});
_listen('parseVerticalMargin', _applyNavBarGaps);
_setupUIElements();
if(_options.shareEl && _shareButton && _shareModal) {
_shareModalHidden = true;
}
_countNumItems();
_setupIdle();
_setupFullscreenAPI();
_setupLoadingIndicator();
};
ui.setIdle = function(isIdle) {
_isIdle = isIdle;
_togglePswpClass(_controls, 'ui--idle', isIdle);
};
ui.update = function() {
// Don't update UI if it's hidden
if(_controlsVisible && pswp.currItem) {
ui.updateIndexIndicator();
if(_options.captionEl) {
_options.addCaptionHTMLFn(pswp.currItem, _captionContainer);
_togglePswpClass(_captionContainer, 'caption--empty', !pswp.currItem.title);
}
_overlayUIUpdated = true;
} else {
_overlayUIUpdated = false;
}
if(!_shareModalHidden) {
_toggleShareModal();
}
_countNumItems();
};
ui.updateFullscreen = function(e) {
if(e) {
// some browsers change window scroll position during the fullscreen
// so PhotoSwipe updates it just in case
setTimeout(function() {
pswp.setScrollOffset( 0, framework.getScrollY() );
}, 50);
}
// toogle pswp--fs class on root element
framework[ (_fullscrenAPI.isFullscreen() ? 'add' : 'remove') + 'Class' ](pswp.template, 'pswp--fs');
};
ui.updateIndexIndicator = function() {
if(_options.counterEl) {
_indexIndicator.innerHTML = (pswp.getCurrentIndex()+1) +
_options.indexIndicatorSep +
_options.getNumItemsFn();
}
};
ui.onGlobalTap = function(e) {
e = e || window.event;
var target = e.target || e.srcElement;
if(_blockControlsTap) {
return;
}
if(e.detail && e.detail.pointerType === 'mouse') {
// close gallery if clicked outside of the image
if(_hasCloseClass(target)) {
pswp.close();
return;
}
if(framework.hasClass(target, 'pswp__img')) {
if(pswp.getZoomLevel() === 1 && pswp.getZoomLevel() <= pswp.currItem.fitRatio) {
if(_options.clickToCloseNonZoomable) {
pswp.close();
}
} else {
pswp.toggleDesktopZoom(e.detail.releasePoint);
}
}
} else {
// tap anywhere (except buttons) to toggle visibility of controls
if(_options.tapToToggleControls) {
if(_controlsVisible) {
ui.hideControls();
} else {
ui.showControls();
}
}
// tap to close gallery
if(_options.tapToClose && (framework.hasClass(target, 'pswp__img') || _hasCloseClass(target)) ) {
pswp.close();
return;
}
}
};
ui.onMouseOver = function(e) {
e = e || window.event;
var target = e.target || e.srcElement;
// add class when mouse is over an element that should close the gallery
_togglePswpClass(_controls, 'ui--over-close', _hasCloseClass(target));
};
ui.hideControls = function() {
framework.addClass(_controls,'pswp__ui--hidden');
_controlsVisible = false;
};
ui.showControls = function() {
_controlsVisible = true;
if(!_overlayUIUpdated) {
ui.update();
}
framework.removeClass(_controls,'pswp__ui--hidden');
};
ui.supportsFullscreen = function() {
var d = document;
return !!(d.exitFullscreen || d.mozCancelFullScreen || d.webkitExitFullscreen || d.msExitFullscreen);
};
ui.getFullscreenAPI = function() {
var dE = document.documentElement,
api,
tF = 'fullscreenchange';
if (dE.requestFullscreen) {
api = {
enterK: 'requestFullscreen',
exitK: 'exitFullscreen',
elementK: 'fullscreenElement',
eventK: tF
};
} else if(dE.mozRequestFullScreen ) {
api = {
enterK: 'mozRequestFullScreen',
exitK: 'mozCancelFullScreen',
elementK: 'mozFullScreenElement',
eventK: 'moz' + tF
};
} else if(dE.webkitRequestFullscreen) {
api = {
enterK: 'webkitRequestFullscreen',
exitK: 'webkitExitFullscreen',
elementK: 'webkitFullscreenElement',
eventK: 'webkit' + tF
};
} else if(dE.msRequestFullscreen) {
api = {
enterK: 'msRequestFullscreen',
exitK: 'msExitFullscreen',
elementK: 'msFullscreenElement',
eventK: 'MSFullscreenChange'
};
}
if(api) {
api.enter = function() {
// disable close-on-scroll in fullscreen
_initalCloseOnScrollValue = _options.closeOnScroll;
_options.closeOnScroll = false;
if(this.enterK === 'webkitRequestFullscreen') {
pswp.template[this.enterK]( Element.ALLOW_KEYBOARD_INPUT );
} else {
return pswp.template[this.enterK]();
}
};
api.exit = function() {
_options.closeOnScroll = _initalCloseOnScrollValue;
return document[this.exitK]();
};
api.isFullscreen = function() { return document[this.elementK]; };
}
return api;
};
};
return PhotoSwipeUI_Default;
});

View file

@ -1,178 +0,0 @@
/*! PhotoSwipe main CSS by Dmitry Semenov | photoswipe.com | MIT license */
/*
Styles for basic PhotoSwipe functionality (sliding area, open/close transitions)
*/
/* pswp = photoswipe */
.pswp {
display: none;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
overflow: hidden;
-ms-touch-action: none;
touch-action: none;
z-index: 1500;
-webkit-text-size-adjust: 100%;
/* create separate layer, to avoid paint on window.onscroll in webkit/blink */
-webkit-backface-visibility: hidden;
outline: none; }
.pswp * {
-webkit-box-sizing: border-box;
box-sizing: border-box; }
.pswp img {
max-width: none; }
/* style is added when JS option showHideOpacity is set to true */
.pswp--animate_opacity {
/* 0.001, because opacity:0 doesn't trigger Paint action, which causes lag at start of transition */
opacity: 0.001;
will-change: opacity;
/* for open/close transition */
-webkit-transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1);
transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1); }
.pswp--open {
display: block; }
.pswp--zoom-allowed .pswp__img {
/* autoprefixer: off */
cursor: -webkit-zoom-in;
cursor: -moz-zoom-in;
cursor: zoom-in; }
.pswp--zoomed-in .pswp__img {
/* autoprefixer: off */
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab; }
.pswp--dragging .pswp__img {
/* autoprefixer: off */
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing; }
/*
Background is added as a separate element.
As animating opacity is much faster than animating rgba() background-color.
*/
.pswp__bg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: #000;
opacity: 0;
transform: translateZ(0);
-webkit-backface-visibility: hidden;
will-change: opacity; }
.pswp__scroll-wrap {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden; }
.pswp__container,
.pswp__zoom-wrap {
-ms-touch-action: none;
touch-action: none;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0; }
/* Prevent selection and tap highlights */
.pswp__container,
.pswp__img {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none; }
.pswp__zoom-wrap {
position: absolute;
width: 100%;
-webkit-transform-origin: left top;
-ms-transform-origin: left top;
transform-origin: left top;
/* for open/close transition */
-webkit-transition: -webkit-transform 333ms cubic-bezier(0.4, 0, 0.22, 1);
transition: transform 333ms cubic-bezier(0.4, 0, 0.22, 1); }
.pswp__bg {
will-change: opacity;
/* for open/close transition */
-webkit-transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1);
transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1); }
.pswp--animated-in .pswp__bg,
.pswp--animated-in .pswp__zoom-wrap {
-webkit-transition: none;
transition: none; }
.pswp__container,
.pswp__zoom-wrap {
-webkit-backface-visibility: hidden; }
.pswp__item {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: hidden; }
.pswp__img {
position: absolute;
width: auto;
height: auto;
top: 0;
left: 0; }
/*
stretched thumbnail or div placeholder element (see below)
style is added to avoid flickering in webkit/blink when layers overlap
*/
.pswp__img--placeholder {
-webkit-backface-visibility: hidden; }
/*
div element that matches size of large image
large image loads on top of it
*/
.pswp__img--placeholder--blank {
background: #222; }
.pswp--ie .pswp__img {
width: 100% !important;
height: auto !important;
left: 0;
top: 0; }
/*
Error message appears when image is not loaded
(JS option errorMsg controls markup)
*/
.pswp__error-msg {
position: absolute;
left: 0;
top: 50%;
width: 100%;
text-align: center;
font-size: 14px;
line-height: 16px;
margin-top: -8px;
color: #CCC; }
.pswp__error-msg a {
color: #CCC;
text-decoration: underline; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
import PhotoSwipe from "photoswipe";
frappe.PhotoSwipe = PhotoSwipe;

View file

@ -25,7 +25,6 @@
// checkbox
--checkbox-right-margin: var(--margin-xs);
--checkbox-size: 14px;
--checkbox-focus-shadow: var(--focus-default);
// date-picker

View file

@ -23,7 +23,6 @@
.dt-cell {
color: var(--text-color) !important;
background-color: var(--bg-color) !important;
}
.frappe-control,

View file

@ -184,7 +184,7 @@
opacity: 0;
font-size: $font-size-lg;
position: absolute;
background: var(--gray-300);
background: var(--gray-400);
border-radius: $border-radius;
.icon {
@ -192,11 +192,6 @@
stroke: $text-color;
}
}
// show zoom button on mobile devices
// @media (max-width: $screen-xs) {
// opacity: 0.5
// }
}
}
}
@ -211,10 +206,6 @@
background: none !important;
}
.pswp__bg {
background-color: #fff !important;
}
.pswp__more-items {
display: flex;
position: absolute;

View file

@ -15,6 +15,7 @@
.kanban {
display: flex;
gap: 0.5em;
overflow-y: hidden;
-ms-overflow-style: none; /* IE and Edge */
@ -27,19 +28,17 @@
.kanban-column {
@include transition();
flex: 0 0 260px;
max-width: 260px;
flex: 1 0 260px;
border-radius: var(--border-radius);
padding: var(--padding-md);
min-height: calc(100vh - 250px);
max-height: calc(100vh - var(--navbar-height) - var(--page-bottom-margin) - 80px);
min-height: calc(100vh - 150px);
.add-card {
@include flex(flex, center, center, null);
@include transition();
color: var(--text-light);
background-color: var(--kanban-new-card-bg);
background-color: var(--bg-color);
height: 27px;
font-size: var(--text-md);
margin-bottom: var(--margin-sm);
@ -105,10 +104,6 @@
margin: 0;
}
}
&:hover {
cursor: pointer;
}
}
.kanban-column-title {
@ -123,6 +118,7 @@
.kanban-title {
@include get_textstyle("lg", "semibold");
margin-left: var(--margin-sm);
cursor: grab;
}
}
@ -182,7 +178,7 @@
.kanban-card-body {
cursor: grab;
padding: var(--padding-sm);
padding: var(--padding-md);
.kanban-title-area {
margin-bottom: 12px;

View file

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

View file

@ -13,7 +13,6 @@ login.bind_events = function () {
login.route();
});
$(".form-login").on("submit", function (event) {
event.preventDefault();
var args = {};
@ -300,13 +299,6 @@ login.login_handlers = (function () {
frappe.ready(function () {
login.bind_events();
if (!window.location.hash) {
window.location.hash = "#login";
} else {
$(window).trigger("hashchange");
}
if (window.show_footer_on_login) {
$("body .web-footer").show();
}

View file

@ -1109,6 +1109,15 @@ class TestDBQuery(FrappeTestCase):
self.assertNotIn("ifnull", frappe.get_all("User", {"name": ("in", (""))}, run=0))
self.assertNotIn("ifnull", frappe.get_all("User", {"name": ("in", ())}, run=0))
def test_coalesce_with_datetime_ops(self):
self.assertNotIn("ifnull", frappe.get_all("User", {"last_active": (">", "2022-01-01")}, run=0))
self.assertNotIn("ifnull", frappe.get_all("User", {"creation": ("<", "2022-01-01")}, run=0))
self.assertNotIn(
"ifnull",
frappe.get_all("User", {"last_active": ("between", ("2022-01-01", "2023-01-01"))}, run=0),
)
self.assertIn("ifnull", frappe.get_all("User", {"last_active": ("<", "2022-01-01")}, run=0))
def test_ambiguous_linked_tables(self):
from frappe.desk.reportview import get

View file

@ -163,6 +163,17 @@ class TestDBUpdate(FrappeTestCase):
self.assertIn(varchar, frappe.db.get_column_type(doctype.name, "name"))
doc.reload() # ensure that docs are still accesible
def test_uuid_link_field(self):
uuid_doctype = new_doctype().update({"autoname": "UUID"}).insert()
self.assertEqual(frappe.db.get_column_type(uuid_doctype.name, "name"), "uuid")
link = "link_field"
referring_doctype = new_doctype(
fields=[{"fieldname": link, "fieldtype": "Link", "options": uuid_doctype.name}]
).insert()
self.assertEqual(frappe.db.get_column_type(referring_doctype.name, link), "uuid")
def get_fieldtype_from_def(field_def):
fieldtuple = frappe.db.type_map.get(field_def.fieldtype, ("", 0))

View file

@ -119,7 +119,6 @@ class TestEmail(FrappeTestCase):
self.assertTrue("CC: test1@example.com" in message)
def test_cc_footer(self):
frappe.conf.use_ssl = True
# test if sending with cc's makes it into header
frappe.sendmail(
recipients=["test@example.com"],
@ -151,10 +150,6 @@ class TestEmail(FrappeTestCase):
in frappe.safe_decode(frappe.flags.sent_mail)
)
# check for email tracker
self.assertTrue("mark_email_as_seen" in frappe.safe_decode(frappe.flags.sent_mail))
frappe.conf.use_ssl = False
def test_expose(self):
from frappe.utils import set_request
from frappe.utils.verified_command import verify_request

View file

@ -217,7 +217,7 @@ class TestOverheadCalls(FrappeAPITestCase):
def test_ping_overheads(self):
self.get(self.method("ping"), {"sid": "Guest"})
with self.assertRedisCallCounts(12), self.assertQueryCount(self.BASE_SQL_CALLS):
with self.assertRedisCallCounts(13), self.assertQueryCount(self.BASE_SQL_CALLS):
self.get(self.method("ping"), {"sid": "Guest"})
def test_list_view_overheads(self):

View file

@ -1,17 +1,22 @@
import os
import time
from datetime import datetime, timedelta
from unittest import TestCase
from unittest.mock import patch
import frappe
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import ScheduledJobType, sync_jobs
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, get_datetime
from frappe.utils.data import now_datetime
from frappe.utils.doctor import purge_pending_jobs
from frappe.utils.scheduler import (
DEFAULT_SCHEDULER_TICK,
_get_last_creation_timestamp,
enqueue_events,
is_dormant,
schedule_jobs_based_on_activity,
sleep_duration,
)
@ -23,7 +28,7 @@ def test_method():
pass
class TestScheduler(TestCase):
class TestScheduler(FrappeTestCase):
def setUp(self):
frappe.db.rollback()
@ -77,6 +82,7 @@ class TestScheduler(TestCase):
job_log.db_set(
"creation", add_days(_get_last_creation_timestamp("Activity Log"), 5), update_modified=False
)
schedule_jobs_based_on_activity.clear_cache()
# inactive site with recent job, don't run
self.assertFalse(
@ -92,6 +98,21 @@ class TestScheduler(TestCase):
)
)
def test_real_time_alignment(self):
test_cases = {
timedelta(minutes=0): DEFAULT_SCHEDULER_TICK,
timedelta(minutes=0, seconds=12): DEFAULT_SCHEDULER_TICK - 12,
timedelta(minutes=1, seconds=12): DEFAULT_SCHEDULER_TICK - (1 * 60 + 12),
timedelta(hours=23, minutes=59): 60,
timedelta(hours=23, minutes=59, seconds=30): 30,
timedelta(minutes=0, seconds=1): DEFAULT_SCHEDULER_TICK - 1,
timedelta(minutes=2): DEFAULT_SCHEDULER_TICK - 2 * 60,
}
for delta, expected_sleep in test_cases.items():
fake_time = datetime(2024, 1, 1) + delta
with self.freeze_time(fake_time, is_utc=True):
self.assertEqual(sleep_duration(DEFAULT_SCHEDULER_TICK), expected_sleep, delta)
def get_test_job(method="frappe.tests.test_scheduler.test_timeout_10", frequency="All") -> ScheduledJobType:
if not frappe.db.exists("Scheduled Job Type", dict(method=method)):

View file

@ -226,14 +226,15 @@ class FrappeTestCase(unittest.TestCase):
frappe.connect()
@contextmanager
def freeze_time(self, time_to_freeze, *args, **kwargs):
def freeze_time(self, time_to_freeze, is_utc=False, *args, **kwargs):
from freezegun import freeze_time
# Freeze time expects UTC or tzaware objects. We have neither, so convert to UTC.
timezone = pytz.timezone(get_system_timezone())
fake_time_with_tz = timezone.localize(get_datetime(time_to_freeze)).astimezone(pytz.utc)
if not is_utc:
# Freeze time expects UTC or tzaware objects. We have neither, so convert to UTC.
timezone = pytz.timezone(get_system_timezone())
time_to_freeze = timezone.localize(get_datetime(time_to_freeze)).astimezone(pytz.utc)
with freeze_time(fake_time_with_tz, *args, **kwargs):
with freeze_time(time_to_freeze, *args, **kwargs):
yield

View file

@ -482,7 +482,9 @@ def execute_in_shell(cmd, verbose=False, low_priority=False, check_exit_code=Fal
print(out)
if failed:
raise Exception("Command failed")
raise frappe.CommandFailedError(
"Command failed", out.decode(errors="replace"), err.decode(errors="replace")
)
return err, out

View file

@ -6,6 +6,7 @@ from collections import defaultdict
from collections.abc import Callable
from contextlib import suppress
from functools import lru_cache
from threading import Thread
from typing import Any, NoReturn
from uuid import uuid4
@ -283,7 +284,7 @@ def start_worker(
rq_password: str | None = None,
burst: bool = False,
strategy: DequeueStrategy | None = DequeueStrategy.DEFAULT,
) -> None: # pragma: no cover
) -> NoReturn: # pragma: no cover
"""Wrapper to start rq worker. Connects to redis and monitors these queues."""
if not strategy:
@ -319,6 +320,23 @@ def start_worker(
)
class FrappeWorker(Worker):
def work(self, *args, **kwargs):
self.start_frappe_scheduler()
kwargs["with_scheduler"] = False # Always disable RQ scheduler
return super().work(*args, **kwargs)
def run_maintenance_tasks(self, *args, **kwargs):
"""Attempt to start a scheduler in case the worker doing scheduling died."""
self.start_frappe_scheduler()
return super().run_maintenance_tasks(*args, **kwargs)
def start_frappe_scheduler(self):
from frappe.utils.scheduler import start_scheduler
Thread(target=start_scheduler, daemon=True).start()
def start_worker_pool(
queue: str | None = None,
num_workers: int = 1,
@ -335,8 +353,9 @@ def start_worker_pool(
# If gc.freeze is done then importing modules before forking allows us to share the memory
import frappe.database.query # sqlparse and indirect imports
import frappe.query_builder # pypika
import frappe.utils.data # common utils
import frappe.utils # common utils
import frappe.utils.safe_exec
import frappe.utils.scheduler
import frappe.utils.typing_validations # any whitelisted method uses this
import frappe.website.path_resolver # all the page types and resolver
@ -363,6 +382,7 @@ def start_worker_pool(
queues=queues,
connection=redis_connection,
num_workers=num_workers,
worker_class=FrappeWorker, # Auto starts scheduler with workerpool
)
pool.start(logging_level=logging_level, burst=burst)

View file

@ -352,12 +352,21 @@ class BackupGenerator:
else:
cmd_string = "tar -cf {0} {1}"
frappe.utils.execute_in_shell(
cmd_string.format(backup_path, files_path),
verbose=self.verbose,
low_priority=True,
check_exit_code=True,
)
try:
frappe.utils.execute_in_shell(
cmd_string.format(backup_path, files_path),
verbose=self.verbose,
low_priority=True,
check_exit_code=True,
)
except frappe.CommandFailedError as e:
if e.err and "file changed as we read it" in e.err:
click.secho(
"Ignoring `tar: file changed as we read it` to prevent backup failure",
fg="red",
)
else:
raise e
def copy_site_config(self):
site_config_backup_path = self.backup_path_conf

View file

@ -318,6 +318,8 @@ def parse_github_url(remote_url: str) -> tuple[str, str] | tuple[None, None]:
def get_source_url(app: str) -> str | None:
"""Get the remote URL of the app."""
pyproject = get_pyproject(app)
if not pyproject:
return
if remote_url := pyproject.get("project", {}).get("urls", {}).get("Repository"):
return remote_url.rstrip("/")

View file

@ -395,27 +395,6 @@ def get_file_name(fname, optional_suffix):
return fname
@frappe.whitelist()
def download_file(file_url):
"""
Download file using token and REST API. Valid session or
token is required to download private files.
Method : GET
Endpoint : frappe.utils.file_manager.download_file
URL Params : file_name = /path/to/file relative to site path
"""
file_doc = frappe.get_doc("File", {"file_url": file_url})
file_doc.check_permission("read")
path = os.path.join(get_files_path(), os.path.basename(file_url))
with open(path, "rb") as fileobj:
filedata = fileobj.read()
frappe.local.response.filename = os.path.basename(file_url)
frappe.local.response.filecontent = filedata
frappe.local.response.type = "download"
@frappe.whitelist()
def add_attachments(doctype, name, attachments):
"""Add attachments to the given DocType"""

View file

@ -8,22 +8,24 @@ Events:
weekly
"""
# imports - standard imports
import datetime
import os
import random
import time
from typing import NoReturn
import pytz
import setproctitle
from croniter import CroniterBadCronError
from filelock import FileLock, Timeout
# imports - module imports
import frappe
from frappe.utils import cint, get_datetime, get_sites, now_datetime
from frappe.utils import cint, get_bench_path, get_datetime, get_sites, now_datetime
from frappe.utils.background_jobs import set_niceness
from frappe.utils.synchronization import filelock
from frappe.utils.caching import redis_cache
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
DEFAULT_SCHEDULER_TICK = 4 * 60
def cprint(*args, **kwargs):
@ -36,7 +38,7 @@ def cprint(*args, **kwargs):
def _proctitle(message):
setproctitle.setproctitle(f"frappe-scheduler: {message}")
setproctitle.setthreadtitle(f"frappe-scheduler: {message}")
def start_scheduler() -> NoReturn:
@ -46,20 +48,40 @@ def start_scheduler() -> NoReturn:
tick = get_scheduler_tick()
set_niceness()
with filelock("scheduler_process", timeout=1, is_global=True):
while True:
_proctitle("idle")
time.sleep(tick)
enqueue_events_for_all_sites()
lock_path = os.path.abspath(os.path.join(get_bench_path(), "config", "scheduler_process"))
try:
lock = FileLock(lock_path)
lock.acquire(blocking=False)
except Timeout:
frappe.logger("scheduler").debug("Scheduler already running")
return
while True:
_proctitle("idle")
time.sleep(sleep_duration(tick))
enqueue_events_for_all_sites()
def sleep_duration(tick):
if tick != DEFAULT_SCHEDULER_TICK:
# Assuming user knows what they want.
return tick
# Sleep until next multiple of tick.
# This makes scheduler aligned with real clock,
# so event scheduled at 12:00 happen at 12:00 and not 12:00:35.
minutes = tick // 60
now = datetime.datetime.now(pytz.UTC)
left_minutes = minutes - now.minute % minutes
next_execution = now.replace(second=0) + datetime.timedelta(minutes=left_minutes)
return (next_execution - now).total_seconds()
def enqueue_events_for_all_sites() -> None:
"""Loop through sites and enqueue events that are not already queued"""
if os.path.exists(os.path.join(".", ".restarting")):
# Don't add task to queue if webserver is in restart mode
return
with frappe.init_site():
sites = get_sites()
@ -99,7 +121,9 @@ def enqueue_events_for_site(site: str) -> None:
def enqueue_events() -> list[str] | None:
if schedule_jobs_based_on_activity():
enqueued_jobs = []
for job_type in frappe.get_all("Scheduled Job Type", filters={"stopped": 0}, fields="*"):
all_jobs = frappe.get_all("Scheduled Job Type", filters={"stopped": 0}, fields="*")
random.shuffle(all_jobs)
for job_type in all_jobs:
job_type = frappe.get_doc(doctype="Scheduled Job Type", **job_type)
try:
if job_type.enqueue():
@ -156,6 +180,7 @@ def disable_scheduler():
toggle_scheduler(False)
@redis_cache(ttl=60 * 60)
def schedule_jobs_based_on_activity(check_time=None):
"""Return True for active sites as defined by `Activity Log`.
Also return True for inactive sites once every 24 hours based on `Scheduled Job Log`."""
@ -215,4 +240,5 @@ def get_scheduler_status():
def get_scheduler_tick() -> int:
return cint(frappe.get_conf().scheduler_tick_interval) or 60
conf = frappe.get_conf()
return cint(conf.scheduler_tick_interval) or DEFAULT_SCHEDULER_TICK

View file

@ -6,6 +6,7 @@ no_cache = 1
import json
import re
from urllib.parse import urlencode
import frappe
import frappe.sessions
@ -18,11 +19,13 @@ CLOSING_SCRIPT_TAG_PATTERN = re.compile(r"</script\>")
def get_context(context):
if frappe.session.user == "Guest":
frappe.throw(_("Log in to access this page."), frappe.PermissionError)
frappe.response["status_code"] = 403
frappe.msgprint(_("Log in to access this page."))
frappe.redirect(f"/login?{urlencode({'redirect-to': frappe.request.path})}")
elif frappe.db.get_value("User", frappe.session.user, "user_type", order_by=None) == "Website User":
frappe.throw(_("You are not permitted to access this page."), frappe.PermissionError)
hooks = frappe.get_hooks()
try:
boot = frappe.sessions.get()
except Exception as e:
@ -42,6 +45,7 @@ def get_context(context):
boot_json = CLOSING_SCRIPT_TAG_PATTERN.sub("", boot_json)
boot_json = json.dumps(boot_json)
hooks = frappe.get_hooks()
include_js = hooks.get("app_include_js", []) + frappe.conf.get("app_include_js", [])
include_css = hooks.get("app_include_css", []) + frappe.conf.get("app_include_css", [])
include_icons = hooks.get("app_include_icons", [])

View file

@ -24,9 +24,9 @@
"@frappe/esbuild-plugin-postcss2": "^0.1.3",
"@fullcalendar/core": "^6.1.11",
"@fullcalendar/daygrid": "^6.1.11",
"@fullcalendar/interaction": "^6.1.11",
"@fullcalendar/list": "^6.1.11",
"@fullcalendar/timegrid": "^6.1.11",
"@fullcalendar/interaction": "^6.1.11",
"@headlessui/vue": "^1.7.16",
"@popperjs/core": "^2.11.2",
"@redis/client": "^1.5.8",
@ -64,6 +64,7 @@
"md5": "^2.3.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.35",
"photoswipe": "^5.4.3",
"pinia": "^2.0.23",
"plyr": "^3.7.8",
"popper.js": "^1.16.0",
@ -92,4 +93,4 @@
"bufferutil": "^4.0.8",
"utf-8-validate": "^6.0.3"
}
}
}

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