From 0f8e164e8e1f3a6ca9a8c119822d5cadb106326d Mon Sep 17 00:00:00 2001 From: David Angulo Date: Thu, 25 Mar 2021 19:38:56 -0600 Subject: [PATCH 001/593] feat: Add 'Automatic' option for desk theme --- frappe/core/doctype/user/user.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 747ace5de6..10e9e63d0e 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -594,7 +594,7 @@ "fieldname": "desk_theme", "fieldtype": "Select", "label": "Desk Theme", - "options": "Light\nDark" + "options": "Light\nDark\nAutomatic" }, { "fieldname": "module_profile", From bfce0ca52f4d6695955c4ae950b2ab1d50fde5c1 Mon Sep 17 00:00:00 2001 From: David Angulo Date: Thu, 25 Mar 2021 19:39:15 -0600 Subject: [PATCH 002/593] feat: Add desk_theme to bootinfo --- frappe/sessions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/sessions.py b/frappe/sessions.py index 3babf1db12..bb54418a17 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -159,6 +159,8 @@ def get(): bootinfo["setup_complete"] = cint(frappe.db.get_single_value('System Settings', 'setup_complete')) bootinfo["is_first_startup"] = cint(frappe.db.get_single_value('System Settings', 'is_first_startup')) + bootinfo['desk_theme'] = frappe.db.get_value("User", frappe.session.user, "desk_theme") or 'Light' + return bootinfo def get_csrf_token(): From 72126a8645e0112c4ce5925d4e54efcc534483fe Mon Sep 17 00:00:00 2001 From: David Angulo Date: Thu, 25 Mar 2021 19:41:19 -0600 Subject: [PATCH 003/593] feat: Change default desk theme behaviour --- frappe/www/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/www/app.py b/frappe/www/app.py index 6088c413dc..656ebbe45c 100644 --- a/frappe/www/app.py +++ b/frappe/www/app.py @@ -46,7 +46,7 @@ def get_context(context): "include_css": hooks["app_include_css"], "sounds": hooks["sounds"], "boot": boot if context.get("for_mobile") else boot_json, - "desk_theme": desk_theme or "Light", + "desk_theme": desk_theme if not desk_theme == 'Automatic' else 'Light', "csrf_token": csrf_token, "google_analytics_id": frappe.conf.get("google_analytics_id"), "google_analytics_anonymize_ip": frappe.conf.get("google_analytics_anonymize_ip"), From 1100180576a3560774f6b7f210479e87ae43297d Mon Sep 17 00:00:00 2001 From: David Angulo Date: Thu, 25 Mar 2021 19:43:51 -0600 Subject: [PATCH 004/593] feat: Add listener to system theme change, to update desk theme accordingly --- frappe/public/js/frappe/desk.js | 6 +++++ frappe/public/js/frappe/ui/theme_switcher.js | 26 +++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 250d308b7e..8094c0261b 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -63,6 +63,12 @@ frappe.Application = Class.extend({ } }); + if(frappe.boot.desk_theme == 'Automatic') { + frappe.ui.add_system_theme_switch_listener(); + const startup_theme = frappe.ui.dark_theme_media_query.matches ? 'dark' : 'light'; + frappe.ui.toggle_theme(startup_theme); + } + this.set_rtl(); // page container diff --git a/frappe/public/js/frappe/ui/theme_switcher.js b/frappe/public/js/frappe/ui/theme_switcher.js index 317198bca5..d78dd6455c 100644 --- a/frappe/public/js/frappe/ui/theme_switcher.js +++ b/frappe/public/js/frappe/ui/theme_switcher.js @@ -82,11 +82,15 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { return preview; } - toggle_theme(theme) { + toggle_theme(theme, save_preferences=true) { this.current_theme = theme.toLowerCase(); document.documentElement.setAttribute("data-theme", this.current_theme); frappe.show_alert("Theme Changed", 3); + if(!save_preferences) { + return; + } + frappe.xcall("frappe.core.doctype.user.user.switch_theme", { theme: toTitle(theme) }); @@ -99,3 +103,23 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { this.dialog.hide(); } }; + +frappe.ui.add_system_theme_switch_listener = function() { + const toggle_theme = frappe.ui.toggle_theme; + + frappe.ui.dark_theme_media_query.addEventListener('change', function(e) { + if (e.matches) { + toggle_theme('dark'); + return; + } + + toggle_theme('light'); + }) +} + +frappe.ui.dark_theme_media_query = window.matchMedia("(prefers-color-scheme: dark)"); + +frappe.ui.toggle_theme = function(theme) { + const theme_switcher = new frappe.ui.ThemeSwitcher(); + theme_switcher.toggle_theme(theme, false); +} \ No newline at end of file From c1fd70b997484797a6f3d464ab4ab807c08fc935 Mon Sep 17 00:00:00 2001 From: David Angulo Date: Thu, 25 Mar 2021 20:51:58 -0600 Subject: [PATCH 005/593] feat: Add options to ThemeSwitcher.toggle_theme to configure if we want to show an alert and save preferences on change --- frappe/public/js/frappe/ui/theme_switcher.js | 22 ++++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/frappe/public/js/frappe/ui/theme_switcher.js b/frappe/public/js/frappe/ui/theme_switcher.js index d78dd6455c..fb43c1fae0 100644 --- a/frappe/public/js/frappe/ui/theme_switcher.js +++ b/frappe/public/js/frappe/ui/theme_switcher.js @@ -82,18 +82,19 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { return preview; } - toggle_theme(theme, save_preferences=true) { + toggle_theme(theme, options = {save_preferences:true, show_alert:true}) { this.current_theme = theme.toLowerCase(); document.documentElement.setAttribute("data-theme", this.current_theme); - frappe.show_alert("Theme Changed", 3); - if(!save_preferences) { - return; + if (options && options.show_alert) { + frappe.show_alert("Theme Changed", 3); + } + + if(options && options.save_preferences) { + frappe.xcall("frappe.core.doctype.user.user.switch_theme", { + theme: toTitle(theme) + }); } - - frappe.xcall("frappe.core.doctype.user.user.switch_theme", { - theme: toTitle(theme) - }); } show() { this.dialog.show(); @@ -121,5 +122,8 @@ frappe.ui.dark_theme_media_query = window.matchMedia("(prefers-color-scheme: dar frappe.ui.toggle_theme = function(theme) { const theme_switcher = new frappe.ui.ThemeSwitcher(); - theme_switcher.toggle_theme(theme, false); + theme_switcher.toggle_theme(theme, { + save_preferences: false, + show_alert: false + }); } \ No newline at end of file From a1579db65dde95520705100176dd7ddff637e5a9 Mon Sep 17 00:00:00 2001 From: David Angulo Date: Thu, 25 Mar 2021 21:08:05 -0600 Subject: [PATCH 006/593] fix: fix sider validation --- frappe/public/js/frappe/desk.js | 2 +- frappe/public/js/frappe/ui/theme_switcher.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 8094c0261b..682f730d3c 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -63,7 +63,7 @@ frappe.Application = Class.extend({ } }); - if(frappe.boot.desk_theme == 'Automatic') { + if (frappe.boot.desk_theme == 'Automatic') { frappe.ui.add_system_theme_switch_listener(); const startup_theme = frappe.ui.dark_theme_media_query.matches ? 'dark' : 'light'; frappe.ui.toggle_theme(startup_theme); diff --git a/frappe/public/js/frappe/ui/theme_switcher.js b/frappe/public/js/frappe/ui/theme_switcher.js index fb43c1fae0..56263824dd 100644 --- a/frappe/public/js/frappe/ui/theme_switcher.js +++ b/frappe/public/js/frappe/ui/theme_switcher.js @@ -82,7 +82,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { return preview; } - toggle_theme(theme, options = {save_preferences:true, show_alert:true}) { + toggle_theme(theme, options = { save_preferences:true, show_alert:true }) { this.current_theme = theme.toLowerCase(); document.documentElement.setAttribute("data-theme", this.current_theme); @@ -90,7 +90,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { frappe.show_alert("Theme Changed", 3); } - if(options && options.save_preferences) { + if (options && options.save_preferences) { frappe.xcall("frappe.core.doctype.user.user.switch_theme", { theme: toTitle(theme) }); @@ -115,8 +115,8 @@ frappe.ui.add_system_theme_switch_listener = function() { } toggle_theme('light'); - }) -} + }); +}; frappe.ui.dark_theme_media_query = window.matchMedia("(prefers-color-scheme: dark)"); @@ -126,4 +126,4 @@ frappe.ui.toggle_theme = function(theme) { save_preferences: false, show_alert: false }); -} \ No newline at end of file +}; \ No newline at end of file From 2f32c4f19698c1031fa2fa83dcd3fd8b19051b4f Mon Sep 17 00:00:00 2001 From: David Angulo Date: Fri, 26 Mar 2021 10:01:18 -0600 Subject: [PATCH 007/593] fix: fix sider validation --- frappe/public/js/frappe/ui/theme_switcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/theme_switcher.js b/frappe/public/js/frappe/ui/theme_switcher.js index 56263824dd..fbf6575bc4 100644 --- a/frappe/public/js/frappe/ui/theme_switcher.js +++ b/frappe/public/js/frappe/ui/theme_switcher.js @@ -82,7 +82,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { return preview; } - toggle_theme(theme, options = { save_preferences:true, show_alert:true }) { + toggle_theme(theme, options = { save_preferences: true, show_alert: true }) { this.current_theme = theme.toLowerCase(); document.documentElement.setAttribute("data-theme", this.current_theme); From e00eb958367fc7567cf1a51f0370e64a57b8f95c Mon Sep 17 00:00:00 2001 From: David Angulo Date: Tue, 6 Apr 2021 10:35:56 -0500 Subject: [PATCH 008/593] fix: Update modified timestamp so the update will pickup the changes --- frappe/core/doctype/user/user.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 10e9e63d0e..4d8d907ee6 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -669,7 +669,7 @@ } ], "max_attachments": 5, - "modified": "2021-02-01 16:11:06.037543", + "modified": "2021-04-06 16:11:06.037543", "modified_by": "Administrator", "module": "Core", "name": "User", From 1952920add38964450ae12121e3f82f628780be8 Mon Sep 17 00:00:00 2001 From: hrwx Date: Tue, 15 Jun 2021 22:53:24 +0530 Subject: [PATCH 009/593] feat: Convert datetime field values to system timezone Co-authored-by: Sahil Khan --- frappe/boot.py | 12 +++- frappe/core/doctype/user/user.js | 7 ++- frappe/core/doctype/user/user.py | 8 +++ .../js/frappe/form/controls/datetime.js | 44 ++++++++++++- frappe/public/js/frappe/utils/datetime.js | 61 +++++++++++-------- 5 files changed, 101 insertions(+), 31 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index 0589e32ac8..feace1a66d 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -17,6 +17,7 @@ from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_p from frappe.model.base_document import get_controller from frappe.social.doctype.post.post import frequently_visited_links from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings, get_app_logo +from frappe.utils import get_time_zone def get_bootinfo(): """build and return boot info""" @@ -58,6 +59,7 @@ def get_bootinfo(): bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1}) bootinfo.navbar_settings = get_navbar_settings() bootinfo.notification_settings = get_notification_settings() + set_time_zone(bootinfo) # ipinfo if frappe.session.data.get('ipinfo'): @@ -220,8 +222,8 @@ def load_translations(bootinfo): bootinfo["__messages"] = messages def get_user_info(): - user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', - 'gender', 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type'], + user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', 'gender', + 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type', 'time_zone'], filters=dict(enabled=1)) user_info_map = {d.name: d for d in user_info} @@ -324,3 +326,9 @@ def get_desk_settings(): def get_notification_settings(): return frappe.get_cached_doc('Notification Settings', frappe.session.user) + +def set_time_zone(bootinfo): + bootinfo.time_zone = { + "system_time_zone": get_time_zone(), + "user_time_zone": bootinfo.get("user_info", {}).get(frappe.session.user, {}).get("time_zone", None) or get_time_zone() + } diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index 8c5b89c5fc..7c9e00d6bc 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -77,7 +77,12 @@ frappe.ui.form.on('User', { } }, refresh: function(frm) { - var doc = frm.doc; + let doc = frm.doc; + + if (frm.is_new()) { + frm.set_value("time_zone", frappe.sys_defaults.time_zone); + } + if (in_list(['System User', 'Website User'], frm.doc.user_type) && !frm.is_new() && !frm.roles_editor && frm.can_edit_roles) { frm.reload_doc(); diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 13063b8fd2..93963c6015 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -80,6 +80,7 @@ class User(Document): self.validate_roles() self.validate_allowed_modules() self.validate_user_image() + self.set_time_zone() if self.language == "Loading...": self.language = None @@ -594,6 +595,13 @@ class User(Document): return user + def set_time_zone(self): + from frappe.utils import get_time_zone + + if not self.time_zone: + frappe.msgprint(_("User Time Zone was not set, defaulting to System Time Zone."), title=_("User Time Zone")) + self.time_zone = get_time_zone() + @frappe.whitelist() def get_timezones(): import pytz diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index 341a933066..c634aac293 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -1,4 +1,16 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.ControlDate { + set_formatted_input(value) { + if (this.timepicker_only) return; + if (!this.datepicker) return; + if (!value) { + this.datepicker.clear(); + return; + } else if (value === "Today") { + value = this.get_now_date(); + } + + this.$input && this.$input.val(this.format_for_input(value)); + } set_date_options() { super.set_date_options(); this.today_text = __("Now"); @@ -14,10 +26,36 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co get_now_date() { return frappe.datetime.now_datetime(true); } + parse(value) { + if (value) { + value = frappe.datetime.user_to_str(value, false); + + if (!frappe.datetime.is_timezone_same()) { + value = frappe.datetime.convert_to_system_tz(value, true); + } + + return value; + } + } + format_for_input(value) { + if (!value) return ""; + + let m = frappe.datetime.is_timezone_same(); + if (!frappe.datetime.is_timezone_same()) { + m = frappe.datetime.convert_to_user_tz(value, true) + value = frappe.datetime.convert_to_user_tz(value, true); + } + + return frappe.datetime.str_to_user(value, false); + } set_description() { - const { description } = this.df; - const { time_zone } = frappe.sys_defaults; - if (!this.df.hide_timezone && !frappe.datetime.is_timezone_same()) { + const description = this.df.description; + const time_zone = frappe.boot.time_zone ? frappe.boot.time_zone.user_time_zone : frappe.sys_defaults.time_zone; + + if (!this.df.hide_timezone) { + // Always show the timezone when rendering the Datetime field since the datetime value will + // always be in system_time_zone rather then local time. + if (!description) { this.df.description = time_zone; } else if (!description.includes(time_zone)) { diff --git a/frappe/public/js/frappe/utils/datetime.js b/frappe/public/js/frappe/utils/datetime.js index 0c6fea2986..3d00762c94 100644 --- a/frappe/public/js/frappe/utils/datetime.js +++ b/frappe/public/js/frappe/utils/datetime.js @@ -13,33 +13,44 @@ frappe.provide("frappe.datetime"); $.extend(frappe.datetime, { convert_to_user_tz: function(date, format) { // format defaults to true - if(frappe.sys_defaults.time_zone) { - var date_obj = moment.tz(date, frappe.sys_defaults.time_zone).local(); + // Converts the datetime string to system time zone first since the database only stores datetime in + // system time zone and then convert the string to user time zone(from User doctype). + let date_obj = null; + if (frappe.boot.time_zone && frappe.boot.time_zone.system_time_zone && frappe.boot.time_zone.user_time_zone) { + date_obj = moment.tz(date, frappe.boot.time_zone.system_time_zone) + .clone() + .tz(frappe.boot.time_zone.user_time_zone); } else { - var date_obj = moment(date); + date_obj = moment(date); } - return (format===false) ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); + return format===false ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); }, convert_to_system_tz: function(date, format) { // format defaults to true - - if(frappe.sys_defaults.time_zone) { - var date_obj = moment(date).tz(frappe.sys_defaults.time_zone); + // Converts the datetime string to user time zone (from User doctype) first since this fn is called in datetime which accepts datetime + // in user time zone then convert the string to user time zone. + // This is done so that only one timezone is present in database and we do not end up storing local timezone since it changes + // as per the location of user. + let date_obj = null; + if (frappe.boot.time_zone && frappe.boot.time_zone.system_time_zone && frappe.boot.time_zone.user_time_zone) { + date_obj = moment.tz(date, frappe.boot.time_zone.user_time_zone) + .clone() + .tz(frappe.boot.time_zone.system_time_zone); } else { - var date_obj = moment(date); + date_obj = moment(date); } - return (format===false) ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); + return format===false ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); }, is_timezone_same: function() { - if(frappe.sys_defaults.time_zone) { - return moment().tz(frappe.sys_defaults.time_zone).utcOffset() === moment().utcOffset(); - } else { - return true; + if (frappe.boot.time_zone && frappe.boot.time_zone.system_time_zone && frappe.boot.time_zone.user_time_zone) { + return moment().tz(frappe.boot.time_zone.system_time_zone).utcOffset() === moment().tz(frappe.boot.time_zone.user_time_zone).utcOffset(); } + + return true; }, str_to_obj: function(d) { @@ -186,18 +197,18 @@ $.extend(frappe.datetime, { }, _date: function(format, as_obj = false) { - const time_zone = frappe.sys_defaults && frappe.sys_defaults.time_zone; - let date; - if (time_zone) { - date = moment.tz(time_zone); - } else { - date = moment(); - } - if (as_obj) { - return frappe.datetime.moment_to_date_obj(date); - } else { - return date.format(format); - } + /** + * Whenever we are getting now_date/datetime, always make sure dates are fetched using usertime zone. + * This is to make sure that time is as per user time zone set in User doctype, If a user had to change the timezone, + * we will end up having multiple timezone by not honouring timezone in User doctype. + * This will make sure that at any point we know which timezone the user if following and not have random timezone + * when the timezone of the local machine changes. + */ + let time_zone = frappe.boot.time_zone.user_time_zone || frappe.boot.time_zone.system_time_zone; + if (!time_zone) time_zone = frappe.sys_defaults.time_zone; + + let date = moment.tz(time_zone); + return as_obj ? frappe.datetime.moment_to_date_obj(date) : date.format(format); }, moment_to_date_obj: function(moment) { From 02640b791f24a9d089e8a65852bcf2ca0f12bf9e Mon Sep 17 00:00:00 2001 From: hrwx Date: Tue, 15 Jun 2021 23:31:30 +0530 Subject: [PATCH 010/593] fix: check if frappe.sys_defaults.time_zone exists --- .../core/doctype/system_settings/system_settings.js | 6 ++++++ frappe/core/doctype/user/user.js | 6 ++++++ frappe/core/doctype/user/user.py | 1 - frappe/public/js/frappe/form/controls/datetime.js | 11 ++++++----- frappe/public/js/frappe/form/controls/time.js | 2 +- frappe/public/js/frappe/form/formatters.js | 8 ++------ frappe/public/js/frappe/utils/datetime.js | 7 +++---- 7 files changed, 24 insertions(+), 17 deletions(-) diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js index c0c9074cbc..aefe3786bd 100644 --- a/frappe/core/doctype/system_settings/system_settings.js +++ b/frappe/core/doctype/system_settings/system_settings.js @@ -32,5 +32,11 @@ frappe.ui.form.on("System Settings", { frm.set_value('prepared_report_expiry_period', 7); } } + }, + after_save: function(frm) { + if (frappe.boot.time_zone && frappe.boot.time_zone.system_time_zone !== frm.doc.time_zone) { + // Clear cache after saving to refresh the values of time_zone + frappe.ui.toolbar.clear_cache(); + } } }); diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index 7c9e00d6bc..819684cdfe 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -271,6 +271,12 @@ frappe.ui.form.on('User', { } } }); + }, + after_save: function(frm) { + if (frappe.boot.time_zone && frappe.boot.time_zone.user_time_zone !== frm.doc.time_zone) { + // Clear cache after saving to refresh the values of time_zone + frappe.ui.toolbar.clear_cache(); + } } }); diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 93963c6015..cf2b045c6d 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -599,7 +599,6 @@ class User(Document): from frappe.utils import get_time_zone if not self.time_zone: - frappe.msgprint(_("User Time Zone was not set, defaulting to System Time Zone."), title=_("User Time Zone")) self.time_zone = get_time_zone() @frappe.whitelist() diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index c634aac293..a8b19c4ece 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -30,7 +30,7 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co if (value) { value = frappe.datetime.user_to_str(value, false); - if (!frappe.datetime.is_timezone_same()) { + if (!frappe.datetime.is_system_time_zone()) { value = frappe.datetime.convert_to_system_tz(value, true); } @@ -40,9 +40,7 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co format_for_input(value) { if (!value) return ""; - let m = frappe.datetime.is_timezone_same(); - if (!frappe.datetime.is_timezone_same()) { - m = frappe.datetime.convert_to_user_tz(value, true) + if (!frappe.datetime.is_system_time_zone()) { value = frappe.datetime.convert_to_user_tz(value, true); } @@ -50,7 +48,7 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co } set_description() { const description = this.df.description; - const time_zone = frappe.boot.time_zone ? frappe.boot.time_zone.user_time_zone : frappe.sys_defaults.time_zone; + const time_zone = this.get_user_time_zone(); if (!this.df.hide_timezone) { // Always show the timezone when rendering the Datetime field since the datetime value will @@ -64,6 +62,9 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co } super.set_description(); } + get_user_time_zone() { + return frappe.boot.time_zone ? frappe.boot.time_zone.user_time_zone : frappe.sys_defaults.time_zone; + } set_datepicker() { super.set_datepicker(); if (this.datepicker.opts.timeFormat.indexOf('s') == -1) { diff --git a/frappe/public/js/frappe/form/controls/time.js b/frappe/public/js/frappe/form/controls/time.js index a7b6645681..f7fcc4c618 100644 --- a/frappe/public/js/frappe/form/controls/time.js +++ b/frappe/public/js/frappe/form/controls/time.js @@ -71,7 +71,7 @@ frappe.ui.form.ControlTime = class ControlTime extends frappe.ui.form.ControlDat set_description() { const { description } = this.df; const { time_zone } = frappe.sys_defaults; - if (!frappe.datetime.is_timezone_same()) { + if (!frappe.datetime.is_system_time_zone()) { if (!description) { this.df.description = time_zone; } else if (!description.includes(time_zone)) { diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index b9a838688d..caecd65336 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -167,12 +167,8 @@ frappe.form.formatters = { }, Datetime: function(value) { if(value) { - var m = moment(frappe.datetime.convert_to_user_tz(value)); - if(frappe.boot.sysdefaults.time_zone) { - m = m.tz(frappe.boot.sysdefaults.time_zone); - } - return m.format(frappe.boot.sysdefaults.date_format.toUpperCase() - + ' ' + frappe.boot.sysdefaults.time_format); + return moment(frappe.datetime.convert_to_user_tz(value)) + .format(frappe.boot.sysdefaults.date_format.toUpperCase() + ' ' + frappe.boot.sysdefaults.time_format); } else { return ""; } diff --git a/frappe/public/js/frappe/utils/datetime.js b/frappe/public/js/frappe/utils/datetime.js index 3d00762c94..ae7308cf7b 100644 --- a/frappe/public/js/frappe/utils/datetime.js +++ b/frappe/public/js/frappe/utils/datetime.js @@ -45,7 +45,7 @@ $.extend(frappe.datetime, { return format===false ? date_obj : date_obj.format(frappe.defaultDatetimeFormat); }, - is_timezone_same: function() { + is_system_time_zone: function() { if (frappe.boot.time_zone && frappe.boot.time_zone.system_time_zone && frappe.boot.time_zone.user_time_zone) { return moment().tz(frappe.boot.time_zone.system_time_zone).utcOffset() === moment().tz(frappe.boot.time_zone.user_time_zone).utcOffset(); } @@ -204,10 +204,9 @@ $.extend(frappe.datetime, { * This will make sure that at any point we know which timezone the user if following and not have random timezone * when the timezone of the local machine changes. */ - let time_zone = frappe.boot.time_zone.user_time_zone || frappe.boot.time_zone.system_time_zone; - if (!time_zone) time_zone = frappe.sys_defaults.time_zone; - + let time_zone = frappe.boot.time_zone ? frappe.boot.time_zone.user_time_zone || frappe.boot.time_zone.system_time_zone : frappe.sys_defaults.time_zone; let date = moment.tz(time_zone); + return as_obj ? frappe.datetime.moment_to_date_obj(date) : date.format(format); }, From d328b65122aecdd1490251b671f5dc203539b848 Mon Sep 17 00:00:00 2001 From: mhbu50 Date: Sat, 24 Jul 2021 21:37:15 +0300 Subject: [PATCH 011/593] fix: Translate Strings --- frappe/templates/print_formats/standard_macros.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/templates/print_formats/standard_macros.html b/frappe/templates/print_formats/standard_macros.html index f8dc6c370c..a6238b65d2 100644 --- a/frappe/templates/print_formats/standard_macros.html +++ b/frappe/templates/print_formats/standard_macros.html @@ -192,9 +192,9 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}" {% else %} From 8a8fbad0f3232c113043b9a86b0a6571a77f88d1 Mon Sep 17 00:00:00 2001 From: leela Date: Wed, 25 Aug 2021 09:16:11 +0530 Subject: [PATCH 012/593] refactor: getting submitted linked reference docs --- frappe/desk/form/linked_with.py | 344 +++++++++++++++++++++++++------ frappe/tests/test_linked_with.py | 113 ++++++++++ 2 files changed, 399 insertions(+), 58 deletions(-) create mode 100644 frappe/tests/test_linked_with.py diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index ae48b7fc6b..0b5fe58f93 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -2,6 +2,9 @@ # MIT License. See license.txt import json from collections import defaultdict +from os import link +import itertools +from typing import List import frappe import frappe.desk.form.load @@ -12,69 +15,296 @@ from frappe.modules import load_doctype_module @frappe.whitelist() -def get_submitted_linked_docs(doctype, name, docs=None, visited=None): +def get_submitted_linked_docs(doctype: str, name: str) -> List[tuple]: + """ Get all the nested submitted documents those are present in referencing tables (dependent tables). + + :param doctype: Document type + :param name: Name of the document + + Usecase: + * User should be able to cancel the linked documents along with the one user trying to cancel. + + Case1: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to sd2-n2 and sd2-n2 is linked to sd3-n3, + Getting submittable linked docs of `sd1-n1`should give both sd2-n2 and sd3-n3. + Case2: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to d2-n2 and d2-n2 is linked to sd3-n3, + Getting submittable linked docs of `sd1-n1`should give None. (because d2-n2 is not a submittable doctype) + Case3: If document sd1-n1 (document name n1 from submittable doctype sd1) is linked to d2-n2 & sd2-n2. d2-n2 is linked to sd3-n3. + Getting submittable linked docs of `sd1-n1`should give sd2-n2. + + Logic: + ----- + 1. We can find linked documents only if we know how the doctypes are related. + 2. As we need only submittable documents, we can limit doctype relations search to submittable doctypes by + finding the relationships(Foreign key references) across submittable doctypes. + 3. Searching for links is going to be a tree like structure where at every level, + you will be finding documents using parent document and parent document links. """ - Get all nested submitted linked doctype linkinfo + tree = SubmittableDocumentTree(doctype, name) + visited_documents = tree.get_all_children() + docs = [] - Arguments: - doctype (str) - The doctype for which get all linked doctypes - name (str) - The docname for which get all linked doctypes + for dt, names in visited_documents.items(): + docs.extend([{'doctype': dt, 'name': name, 'docstatus': 1} for name in names]) - Keyword Arguments: - docs (list of dict) - (Optional) Get list of dictionary for linked doctype. - - Returns: - dict - Return list of documents and link count - """ - - if not docs: - docs = [] - - if not visited: - visited = {} - - if doctype not in visited: - visited[doctype] = [] - - if name in visited[doctype]: - return - - linkinfo = get_linked_doctypes(doctype) - linked_docs = get_linked_docs(doctype, name, linkinfo) - - link_count = 0 - visited[doctype].append(name) - - for link_doctype, link_names in linked_docs.items(): - - for link in link_names: - if link['name'] == name: - continue - - docinfo = link.update({"doctype": link_doctype}) - validated_doc = validate_linked_doc(docinfo) - - if not validated_doc: - continue - - link_count += 1 - - links = get_submitted_linked_docs(link_doctype, link.name, docs, visited) - if links: - docs.append({ - "doctype": link_doctype, - "name": link.name, - "docstatus": link.docstatus, - "link_count": links.get("count") - }) - - # sort linked documents by ascending number of links - docs.sort(key=lambda doc: doc.get("link_count")) return { "docs": docs, - "count": link_count + "count": len(docs) } +class SubmittableDocumentTree: + def __init__(self, doctype: str, name: str): + """Construct a tree for the submitable linked documents. + + * Node has properties like doctype and docnames. Represented as Node(doctype, docnames). + * Nodes are linked by doctype relationships like table, link and dynamic links. + * Node is referenced(linked) by many other documents and those are the child nodes. + + NOTE: child document is a property of child node (not same as Frappe child docs of a table field). + """ + self.root_doctype = doctype + self.root_docname = name + + # Documents those are yet to be visited for linked documents. + self.to_be_visited_documents = {doctype: [name]} + self.visited_documents = defaultdict(list) + + self._submittable_doctypes = None # All submittable doctypes in the system + self._references_across_doctypes = None # doctype wise links/references + + def get_all_children(self): + """Get all nodes of a tree except the root node (all the nested submitted + documents those are present in referencing tables (dependent tables). + """ + while self.to_be_visited_documents: + next_level_children = defaultdict(list) + for parent_dt in list(self.to_be_visited_documents): + parent_docs = self.to_be_visited_documents.get(parent_dt) + if not parent_docs: + del self.to_be_visited_documents[parent_dt] + continue + + child_docs = self.get_next_level_children(parent_dt, parent_docs) + self.visited_documents[parent_dt].extend(parent_docs) + for linked_dt, linked_names in child_docs.items(): + not_visited_child_docs = set(linked_names) - set(self.visited_documents.get(linked_dt, [])) + next_level_children[linked_dt].extend(not_visited_child_docs) + + self.to_be_visited_documents = next_level_children + + # Remove root node from visited documents + if self.root_docname in self.visited_documents.get(self.root_doctype, []): + self.visited_documents[self.root_doctype].remove(self.root_docname) + + return self.visited_documents + + def get_next_level_children(self, parent_dt, parent_names): + """Get immediate children of a Node(parent_dt, parent_names) + """ + referencing_fields = self.get_doctype_references(parent_dt) + + child_docs = defaultdict(list) + for field in referencing_fields: + links = get_referencing_documents(parent_dt, parent_names.copy(), field, get_parent_if_child_table_doc=True, + parent_filters=[('docstatus', '=', 1)], allowed_parents=self.get_link_sources()) or {} + for dt, names in links.items(): + child_docs[dt].extend(names) + return child_docs + + def get_doctype_references(self, doctype): + """Get references for a given document. + """ + if self._references_across_doctypes is None: + get_links_to = self.get_document_sources() + limit_link_doctypes = self.get_link_sources() + self._references_across_doctypes = get_references_across_doctypes( + get_links_to, limit_link_doctypes) + return self._references_across_doctypes.get(doctype, []) + + def get_document_sources(self): + """Returns list of doctypes from where we access submittable documents. + """ + return list(set(self.get_link_sources() + [self.root_doctype])) + + def get_link_sources(self): + """limit doctype links to these doctypes. + """ + return list(set(self.get_submittable_doctypes()) - set(get_exempted_doctypes() or [])) + + def get_submittable_doctypes(self) -> List[str]: + """Returns list of submittable doctypes. + """ + if not self._submittable_doctypes: + self._submittable_doctypes = frappe.db.get_list('DocType', {'is_submittable': 1}, pluck='name') + return self._submittable_doctypes + + +def get_child_tables_of_doctypes(doctypes: List[str]=None): + """Returns child tables by doctype. + """ + filters=[['fieldtype','=', 'Table']] + filters_for_docfield = filters + filters_for_customfield = filters + + if doctypes: + filters_for_docfield = filters + [['parent', 'in', tuple(doctypes)]] + filters_for_customfield = filters + [['dt', 'in', tuple(doctypes)]] + + links = frappe.get_all("DocField", + fields=["parent", "fieldname", "options as child_table"], + filters=filters_for_docfield, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as parent", "fieldname", "options as child_table"], + filters=filters_for_customfield, + as_list=1) + + child_tables_by_doctype = defaultdict(list) + for doctype, fieldname, child_table in links: + child_tables_by_doctype[doctype].append( + {'doctype': doctype, 'fieldname': fieldname, 'child_table': child_table}) + return child_tables_by_doctype + + +def get_references_across_doctypes(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None) -> List: + """Find doctype wise foreign key references. + + :param to_doctypes: Get links of these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + + * Include child table, link and dynamic link references. + """ + if limit_link_doctypes: + child_tables_by_doctype = get_child_tables_of_doctypes(limit_link_doctypes) + all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())] + limit_link_doctypes = limit_link_doctypes + all_child_tables + else: + child_tables_by_doctype = get_child_tables_of_doctypes() + all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())] + + references_by_link_fields = get_references_across_doctypes_by_link_field(to_doctypes, limit_link_doctypes) + references_by_dlink_fields = get_references_across_doctypes_by_dynamic_link_field(to_doctypes, limit_link_doctypes) + + references = references_by_link_fields.copy() + for k, v in references_by_dlink_fields.items(): + references.setdefault(k, []).extend(v) + + for doctype, links in references.items(): + for link in links: + link['is_child'] = (link['doctype'] in all_child_tables) + return references + + +def get_references_across_doctypes_by_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None): + """Find doctype wise foreign key references based on link fields. + + :param to_doctypes: Get links to these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + """ + filters=[['fieldtype','=', 'Link']] + + if to_doctypes: + filters += [['options', 'in', tuple(to_doctypes)]] + + filters_for_docfield = filters[:] + filters_for_customfield = filters[:] + + if limit_link_doctypes: + filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]] + filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]] + + links = frappe.get_all("DocField", + fields=["parent", "fieldname", "options as linked_to"], + filters=filters_for_docfield, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as parent", "fieldname", "options as linked_to"], + filters=filters_for_customfield, + as_list=1) + + links_by_doctype = defaultdict(list) + for doctype, fieldname, linked_to in links: + links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname}) + return links_by_doctype + + +def get_references_across_doctypes_by_dynamic_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None): + """Find doctype wise foreign key references based on dynamic link fields. + + :param to_doctypes: Get links to these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + """ + + filters=[['fieldtype','=', 'Dynamic Link']] + + filters_for_docfield = filters[:] + filters_for_customfield = filters[:] + + if limit_link_doctypes: + filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]] + filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]] + + # find dynamic links of parents + links = frappe.get_all("DocField", + fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters_for_docfield, + as_list=1) + + links += frappe.get_all("Custom Field", + fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters_for_customfield, + as_list=1) + + links_by_doctype = defaultdict(list) + for doctype, fieldname, doctype_fieldname in links: + try: + filters = [[doctype_fieldname, 'in', to_doctypes]] if to_doctypes else [] + for linked_to in frappe.db.get_all(doctype, pluck=doctype_fieldname, filters = filters, distinct=1): + if linked_to: + links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname, 'doctype_fieldname': doctype_fieldname}) + except frappe.db.ProgrammingError as e: + # TODO: FIXME + continue + return links_by_doctype + +def get_referencing_documents(reference_doctype: str, reference_names: List[str], + link_info: dict, get_parent_if_child_table_doc: bool=True, + parent_filters: List[list]=None, child_filters=None, allowed_parents=None): + """Get linked documents based on link_info. + + :param reference_doctype: reference doctype to find links + :param reference_names: reference document names to find links for + :param link_info: linking details to get the linked documents + Ex: {'doctype': 'Purchase Invoice Advance', 'fieldname': 'reference_name', + 'doctype_fieldname': 'reference_type', 'is_child': True} + :param get_parent_if_child_table_doc: Get parent record incase linked document is a child table record. + :param parent_filters: filters to apply on if not a child table. + :param child_filters: apply filters if it is a child table. + :param allowed_parents: list of parents allowed in case of get_parent_if_child_table_doc + is enabled. + """ + from_table = link_info['doctype'] + filters = [[link_info['fieldname'], 'in', tuple(reference_names)]] + if link_info.get('doctype_fieldname'): + filters.append([link_info['doctype_fieldname'], '=', reference_doctype]) + + if not link_info.get('is_child'): + filters.extend(parent_filters or []) + return {from_table: frappe.db.get_all(from_table, filters, pluck='name')} + + + filters.extend(child_filters or []) + res = frappe.db.get_all(from_table, filters = filters, fields = ['name', 'parenttype', 'parent']) + documents = defaultdict(list) + + for parent, rows in itertools.groupby(res, key = lambda row: row['parenttype']): + if allowed_parents and parent not in allowed_parents: + continue + filters = (parent_filters or []) + [['name', 'in', tuple([row.parent for row in rows])]] + documents[parent].extend(frappe.db.get_all(parent, filters=filters, pluck='name') or []) + return documents + @frappe.whitelist() def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=[]): @@ -107,7 +337,6 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]): Returns: bool: True if linked document passes all validations, else False """ - #ignore doctype to cancel if docinfo.get("doctype") in ignore_doctypes_on_cancel_all: return False @@ -130,7 +359,6 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]): def get_exempted_doctypes(): """ Get list of doctypes exempted from being auto-cancelled """ - auto_cancel_exempt_doctypes = [] for doctypes in frappe.get_hooks('auto_cancel_exempted_doctypes'): auto_cancel_exempt_doctypes.append(doctypes) diff --git a/frappe/tests/test_linked_with.py b/frappe/tests/test_linked_with.py new file mode 100644 index 0000000000..64da8e51e0 --- /dev/null +++ b/frappe/tests/test_linked_with.py @@ -0,0 +1,113 @@ +import frappe, unittest +from frappe.core.doctype.doctype.test_doctype import new_doctype +from frappe.desk.form import linked_with + + +class TestLinkedWith(unittest.TestCase): + def setUp(self): + parent_doc = new_doctype("Parent Doc") + parent_doc.is_submittable = 1 + parent_doc.insert() + + child_doc1 = new_doctype("Child Doc1", + fields=[ + { + "label": "Parent Doc", + "fieldname": "parent_doc", + "fieldtype": "Link", + "options": "Parent Doc" + }, + { + "label": "Reference field", + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "options": "reference_doctype" + }, + { + "label": "Reference Doctype", + "fieldname": "reference_doctype", + "fieldtype": "Link", + "options": "DocType" + } + + ], unique=0) + child_doc1.is_submittable = 1 + child_doc1.insert() + + child_doc2 = new_doctype("Child Doc2", + fields=[ + { + "label": "Parent Doc", + "fieldname": "parent_doc", + "fieldtype": "Link", + "options": "Parent Doc" + }, + { + "label": "Child Doc1", + "fieldname": "child_doc1", + "fieldtype": "Link", + "options": "Child Doc1" + } + + ], unique=0) + child_doc2.is_submittable = 1 + child_doc2.insert() + + def tearDown(self): + for doctype in ['Parent Doc', 'Child Doc1', 'Child Doc2']: + frappe.delete_doc("DocType", doctype) + + def test_get_doctype_references_by_link_field(self): + references = linked_with.get_references_across_doctypes_by_link_field(to_doctypes = ['Parent Doc']) + self.assertEqual(len(references['Parent Doc']), 3) + self.assertIn({'doctype': 'Child Doc1', 'fieldname': 'parent_doc'}, references['Parent Doc']) + self.assertIn({'doctype': 'Child Doc2', 'fieldname': 'parent_doc'}, references['Parent Doc']) + + references = linked_with.get_references_across_doctypes_by_link_field(to_doctypes = ['Child Doc1']) + self.assertEqual(len(references['Child Doc1']), 2) + self.assertIn({'doctype': 'Child Doc2', 'fieldname': 'child_doc1'}, references['Child Doc1']) + + references = linked_with.get_references_across_doctypes_by_link_field( + to_doctypes = ['Child Doc1', 'Parent Doc'], limit_link_doctypes=['Child Doc1']) + self.assertEqual(len(references['Child Doc1']), 1) + self.assertEqual(len(references['Parent Doc']), 1) + self.assertIn({'doctype': 'Child Doc1', 'fieldname': 'parent_doc'}, references['Parent Doc']) + + def test_get_doctype_references_by_dlink_field(self): + references = linked_with.get_references_across_doctypes_by_dynamic_link_field( + to_doctypes = ['Parent Doc'], limit_link_doctypes = ['Parent Doc', 'Child Doc1', 'Child Doc2']) + self.assertFalse(references) + + parent_record = frappe.get_doc({'doctype': 'Parent Doc'}).insert() + + child_record = frappe.get_doc({ + 'doctype': 'Child Doc1', + 'reference_doctype': 'Parent Doc', + 'reference_name': parent_record.name + }).insert() + + references = linked_with.get_references_across_doctypes_by_dynamic_link_field( + to_doctypes = ['Parent Doc'], limit_link_doctypes = ['Parent Doc', 'Child Doc1', 'Child Doc2']) + + self.assertEqual(len(references['Parent Doc']), 1) + self.assertEqual(references['Parent Doc'][0]['doctype'], 'Child Doc1') + self.assertEqual(references['Parent Doc'][0]['doctype_fieldname'], 'reference_doctype') + + child_record.delete() + parent_record.delete() + + def test_get_submitted_linked_docs(self): + parent_record = frappe.get_doc({'doctype': 'Parent Doc'}).insert() + + child_record = frappe.get_doc({ + 'doctype': 'Child Doc1', + 'reference_doctype': 'Parent Doc', + 'reference_name': parent_record.name, + 'docstatus': 1 + }).insert() + + linked_docs = linked_with.get_submitted_linked_docs(parent_record.doctype, parent_record.name)["docs"] + self.assertIn(child_record.name,linked_docs[0]['name']) + child_record.cancel() + child_record.delete() + parent_record.delete() From d04016d274195060bbe0d9e47f2a7e7803ff1372 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 4 Oct 2021 21:57:10 +0530 Subject: [PATCH 013/593] fix: Fixed css for Primary Navbar template --- frappe/website/web_template/primary_navbar/primary_navbar.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/website/web_template/primary_navbar/primary_navbar.html b/frappe/website/web_template/primary_navbar/primary_navbar.html index 5d7267706a..b18ff6e81f 100644 --- a/frappe/website/web_template/primary_navbar/primary_navbar.html +++ b/frappe/website/web_template/primary_navbar/primary_navbar.html @@ -1,4 +1,4 @@ -