diff --git a/.eslintrc b/.eslintrc index c55acc5bac..a80d2910fa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -80,6 +80,7 @@ "validate_email": true, "validate_name": true, "validate_phone": true, + "validate_url": true, "get_number_format": true, "format_number": true, "format_currency": true, diff --git a/cypress/fixtures/data_field_validation_doctype.js b/cypress/fixtures/data_field_validation_doctype.js new file mode 100644 index 0000000000..da091af7e5 --- /dev/null +++ b/cypress/fixtures/data_field_validation_doctype.js @@ -0,0 +1,65 @@ +export default { + name: 'Validation Test', + custom: 1, + actions: [], + creation: '2019-03-15 06:29:07.215072', + doctype: 'DocType', + editable_grid: 1, + engine: 'InnoDB', + fields: [ + { + fieldname: 'email', + fieldtype: 'Data', + label: 'Email', + options: 'Email' + }, + { + fieldname: 'URL', + fieldtype: 'Data', + label: 'URL', + options: 'URL' + }, + { + fieldname: 'Phone', + fieldtype: 'Data', + label: 'Phone', + options: 'Phone' + }, + { + fieldname: 'person_name', + fieldtype: 'Data', + label: 'Person Name', + options: 'Name' + }, + { + fieldname: 'read_only_url', + fieldtype: 'Data', + label: 'Read Only URL', + options: 'URL', + read_only: '1', + default: 'https://frappe.io' + } + ], + issingle: 1, + links: [], + modified: '2021-04-19 14:40:53.127615', + modified_by: 'Administrator', + module: 'Custom', + owner: 'Administrator', + permissions: [ + { + create: 1, + delete: 1, + email: 1, + print: 1, + read: 1, + role: 'System Manager', + share: 1, + write: 1 + } + ], + quick_entry: 1, + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; diff --git a/cypress/integration/data_field_form_validation.js b/cypress/integration/data_field_form_validation.js new file mode 100644 index 0000000000..c6feea5550 --- /dev/null +++ b/cypress/integration/data_field_form_validation.js @@ -0,0 +1,43 @@ +import data_field_validation_doctype from '../fixtures/data_field_validation_doctype'; +const doctype_name = data_field_validation_doctype.name; + + +context('Data Field Input Validation in New Form', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + return cy.insert_doc('DocType', data_field_validation_doctype, true); + }); + + function validateField(fieldname, invalid_value, valid_value) { + // Invalid, should have has-error class + cy.get_field(fieldname).clear().type(invalid_value).blur(); + cy.get(`.frappe-control[data-fieldname="${fieldname}"]`).should('have.class', 'has-error'); + // Valid value, should not have has-error class + cy.get_field(fieldname).clear().type(valid_value); + cy.get(`.frappe-control[data-fieldname="${fieldname}"]`).should('not.have.class', 'has-error'); + } + + describe('Data Field Options', () => { + it('should validate email address', () => { + cy.new_form(doctype_name); + validateField('email', 'captian', 'hello@test.com'); + }); + + it('should validate URL', () => { + validateField('url', 'jkl', 'https://frappe.io'); + validateField('url', 'abcd.com', 'http://google.com/home'); + validateField('url', '&&http://google.uae', 'gopher://frappe.io'); + validateField('url', 'ftt2:://google.in?q=news', 'ftps2://frappe.io/__/#home'); + validateField('url', 'ftt2://', 'ntps://localhost'); // For intranet URLs + }); + + it('should validate phone number', () => { + validateField('phone', 'america', '89787878'); + }); + + it('should validate name', () => { + validateField('person_name', ' 777Hello', 'James Bond'); + }); + }); +}); \ No newline at end of file diff --git a/cypress/integration/url_data_field.js b/cypress/integration/url_data_field.js new file mode 100644 index 0000000000..cf22c62363 --- /dev/null +++ b/cypress/integration/url_data_field.js @@ -0,0 +1,43 @@ +import data_field_validation_doctype from '../fixtures/data_field_validation_doctype'; + +const doctype_name = data_field_validation_doctype.name; + +context('URL Data Field Input', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + return cy.insert_doc('DocType', data_field_validation_doctype, true); + }); + + + describe('URL Data Field Input ', () => { + it('should not show URL link button without focus', () => { + cy.new_form(doctype_name); + cy.get_field('url').clear().type('https://frappe.io'); + cy.get_field('url').blur().wait(500); + cy.get('.link-btn').should('not.be.visible'); + }); + + it('should show URL link button on focus', () => { + cy.get_field('url').focus().wait(500); + cy.get('.link-btn').should('be.visible'); + }); + + it('should not show URL link button for invalid URL', () => { + cy.get_field('url').clear().type('fuzzbuzz'); + cy.get('.link-btn').should('not.be.visible'); + }); + + it('should have valid URL link with target _blank', () => { + cy.get_field('url').clear().type('https://frappe.io'); + cy.get('.link-btn .btn-open').should('have.attr', 'href', 'https://frappe.io'); + cy.get('.link-btn .btn-open').should('have.attr', 'target', '_blank'); + }); + + it('should inject anchor tag in read-only URL data field', () => { + cy.get('[data-fieldname="read_only_url"]') + .find('a') + .should('have.attr', 'target', '_blank'); + }); + }); +}); \ No newline at end of file diff --git a/frappe/app.py b/frappe/app.py index c9e993a853..794d0f18af 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -201,12 +201,20 @@ def handle_exception(e): response = None http_status_code = getattr(e, "http_status_code", 500) return_as_message = False + accept_header = frappe.get_request_header("Accept") or "" + respond_as_json = ( + frappe.get_request_header('Accept') + and (frappe.local.is_ajax or 'application/json' in accept_header) + or ( + frappe.local.request.path.startswith("/api/") and not accept_header.startswith("text") + ) + ) if frappe.conf.get('developer_mode'): # don't fail silently print(frappe.get_traceback()) - if frappe.get_request_header('Accept') and (frappe.local.is_ajax or 'application/json' in frappe.get_request_header('Accept')): + if respond_as_json: # handle ajax responses first # if the request is ajax, send back the trace or error message response = frappe.utils.response.report_error(http_status_code) diff --git a/frappe/boot.py b/frappe/boot.py index 65a07b15e5..0dfcb8d1b4 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -42,8 +42,6 @@ def get_bootinfo(): bootinfo.user_info = get_user_info() bootinfo.sid = frappe.session['sid'] - bootinfo.user_groups = frappe.get_all('User Group', pluck="name") - bootinfo.modules = {} bootinfo.module_list = [] load_desktop_data(bootinfo) diff --git a/frappe/change_log/v13/v13_2_0.md b/frappe/change_log/v13/v13_2_0.md new file mode 100644 index 0000000000..6fc3eec5e3 --- /dev/null +++ b/frappe/change_log/v13/v13_2_0.md @@ -0,0 +1,32 @@ +# Version 13.2.0 Release Notes + +### Features & Enhancements + +- Add option to mention a group of users ([#12844](https://github.com/frappe/frappe/pull/12844)) +- Copy DocType / documents across sites ([#12872](https://github.com/frappe/frappe/pull/12872)) +- Scheduler log in notifications ([#1135](https://github.com/frappe/frappe/pull/1135)) +- Add Enable/Disable Webhook via Check Field ([#12842](https://github.com/frappe/frappe/pull/12842)) +- Allow query/custom reports to save custom data in the json field ([#12534](https://github.com/frappe/frappe/pull/12534)) + +### Fixes + +- Load server translations in boot (backport #12848) ([#12852](https://github.com/frappe/frappe/pull/12852)) +- Allow to override dashboard chart properties type/color ([#12846](https://github.com/frappe/frappe/pull/12846)) +- Multi-column paste in grid ([#12861](https://github.com/frappe/frappe/pull/12861)) +- Add log_error and FrappeClient to restricted python ([#12857](https://github.com/frappe/frappe/pull/12857)) +- Redirect Web Form user directly to success URL, if no amount is due ([#12661](https://github.com/frappe/frappe/pull/12661)) +- Attachment pill lock icon redirects to File ([#12864](https://github.com/frappe/frappe/pull/12864)) +- Redirect Web Form user directly to success URL, if no amount is due (backport #12661) ([#12856](https://github.com/frappe/frappe/pull/12856)) +- Remove events to redraw charts ([#12973](https://github.com/frappe/frappe/pull/12973)) +- Don't allow user to remove/change data source file in data import ([#12827](https://github.com/frappe/frappe/pull/12827)) +- Load server translations in boot ([#12848](https://github.com/frappe/frappe/pull/12848)) +- Newly created Workspace not being accessible unless a shortcut u… ([#12866](https://github.com/frappe/frappe/pull/12866)) +- Currency labels in grids ([#12974](https://github.com/frappe/frappe/pull/12974)) +- Handle error while session start ([#12933](https://github.com/frappe/frappe/pull/12933)) +- Add field type check in custom field validation ([#12858](https://github.com/frappe/frappe/pull/12858)) +- Make language select optional and fix breakpoint issues ([#12860](https://github.com/frappe/frappe/pull/12860)) +- Form Dashboard reference link ([#12945](https://github.com/frappe/frappe/pull/12945)) +- Invalid HTML generated by the base template ([#12953](https://github.com/frappe/frappe/pull/12953)) +- Default values were not triggering change event ([#12975](https://github.com/frappe/frappe/pull/12975)) +- Make strings translatable ([#12877](https://github.com/frappe/frappe/pull/12877)) +- Added build-message-files command ([#12950](https://github.com/frappe/frappe/pull/12950)) \ No newline at end of file diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 0102d3ac40..ebd2700c9c 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -1,6 +1,7 @@ # imports - standard imports import os import sys +import shutil # imports - third party imports import click @@ -202,10 +203,13 @@ def install_app(context, apps): @click.command("list-apps") +@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text") @pass_context -def list_apps(context): +def list_apps(context, format): "List apps in site" + summary_dict = {} + def fix_whitespaces(text): if site == context.sites[-1]: text = text.rstrip() @@ -234,18 +238,25 @@ def list_apps(context): ] applications_summary = "\n".join(installed_applications) summary = f"{site_title}\n{applications_summary}\n" + summary_dict[site] = [app.app_name for app in apps] else: - applications_summary = "\n".join(frappe.get_installed_apps()) + installed_applications = frappe.get_installed_apps() + applications_summary = "\n".join(installed_applications) summary = f"{site_title}\n{applications_summary}\n" + summary_dict[site] = installed_applications summary = fix_whitespaces(summary) - if applications_summary and summary: + if format == "text" and applications_summary and summary: print(summary) frappe.destroy() + if format == "json": + import json + + click.echo(json.dumps(summary_dict)) @click.command('add-system-manager') @click.argument('email') @@ -547,7 +558,7 @@ def move(dest_dir, site): site_dump_exists = os.path.exists(final_new_path) count = int(count or 0) + 1 - os.rename(old_path, final_new_path) + shutil.move(old_path, final_new_path) frappe.destroy() return final_new_path diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 65557f62d3..dbca28a3a3 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -531,7 +531,7 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal # Generate coverage report only for app that is being tested source_path = os.path.join(get_bench_path(), 'apps', app or 'frappe') - cov = Coverage(source=[source_path], omit=[ + omit=[ '*.html', '*.js', '*.xml', @@ -541,7 +541,12 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal '*.vue', '*/doctype/*/*_dashboard.py', '*/patches/*' - ]) + ] + + if not app or app == 'frappe': + omit.append('*/commands/*') + + cov = Coverage(source=[source_path], omit=omit) cov.start() ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index fe5038b841..7f93d3130a 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -662,4 +662,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 0462de8643..a4d13a57e0 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -56,6 +56,7 @@ class User(Document): def after_insert(self): create_notification_settings(self.name) + frappe.cache().delete_key('users_for_mentions') def validate(self): self.check_demo() @@ -129,6 +130,9 @@ class User(Document): if self.time_zone: frappe.defaults.set_default("time_zone", self.time_zone, self.name) + if self.has_value_changed('allow_in_mentions') or self.has_value_changed('user_type'): + frappe.cache().delete_key('users_for_mentions') + def has_website_permission(self, ptype, user, verbose=False): """Returns true if current user is the session user""" return self.name == frappe.session.user @@ -389,6 +393,9 @@ class User(Document): # delete notification settings frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True) + if self.get('allow_in_mentions'): + frappe.cache().delete_key('users_for_mentions') + def before_rename(self, old_name, new_name, merge=False): self.check_demo() diff --git a/frappe/core/doctype/user_group/user_group.py b/frappe/core/doctype/user_group/user_group.py index 64bffa06d0..b1d0fede4c 100644 --- a/frappe/core/doctype/user_group/user_group.py +++ b/frappe/core/doctype/user_group/user_group.py @@ -9,7 +9,7 @@ import frappe class UserGroup(Document): def after_insert(self): - frappe.publish_realtime('user_group_added', self.name) + frappe.cache().delete_key('user_groups') def on_trash(self): - frappe.publish_realtime('user_group_deleted', self.name) + frappe.cache().delete_key('user_groups') diff --git a/frappe/core/page/recorder/recorder.js b/frappe/core/page/recorder/recorder.js index 3de0e50cb2..f1f74daf71 100644 --- a/frappe/core/page/recorder/recorder.js +++ b/frappe/core/page/recorder/recorder.js @@ -1,7 +1,7 @@ frappe.pages['recorder'].on_page_load = function(wrapper) { frappe.ui.make_app_page({ parent: wrapper, - title: 'Recorder', + title: __('Recorder'), single_column: true, card_layout: true }); diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 442b8dbb31..1807678673 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -278,6 +278,7 @@ }, { "collapsible": 1, + "depends_on": "doc_type", "fieldname": "naming_section", "fieldtype": "Section Break", "label": "Naming" @@ -287,6 +288,16 @@ "fieldname": "autoname", "fieldtype": "Data", "label": "Auto Name" + }, + { + "fieldname": "default_email_template", + "fieldtype": "Link", + "label": "Default Email Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_26", + "fieldtype": "Column Break" } ], "hide_toolbar": 1, @@ -295,7 +306,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-03-22 12:27:15.462727", + "modified": "2021-04-29 21:21:06.476372", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", @@ -316,4 +327,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/desk/page/backups/backups.css b/frappe/desk/page/backups/backups.css index 13f093e0b1..32ccb88c37 100644 --- a/frappe/desk/page/backups/backups.css +++ b/frappe/desk/page/backups/backups.css @@ -5,6 +5,7 @@ .download-backup-card { display: block; text-decoration: none; + margin-bottom: var(--margin-lg); } .download-backup-card:hover { diff --git a/frappe/desk/page/backups/backups.js b/frappe/desk/page/backups/backups.js index c82407c6bd..337ad33f43 100644 --- a/frappe/desk/page/backups/backups.js +++ b/frappe/desk/page/backups/backups.js @@ -1,7 +1,7 @@ frappe.pages['backups'].on_page_load = function(wrapper) { var page = frappe.ui.make_app_page({ parent: wrapper, - title: 'Download Backups', + title: __('Download Backups'), single_column: true }); diff --git a/frappe/desk/page/translation_tool/translation_tool.js b/frappe/desk/page/translation_tool/translation_tool.js index b3f0c032e3..13f68e647a 100644 --- a/frappe/desk/page/translation_tool/translation_tool.js +++ b/frappe/desk/page/translation_tool/translation_tool.js @@ -1,7 +1,7 @@ frappe.pages['translation-tool'].on_page_load = function(wrapper) { var page = frappe.ui.make_app_page({ parent: wrapper, - title: 'Translation Tool', + title: __('Translation Tool'), single_column: true, card_layout: true, }); diff --git a/frappe/desk/page/user_profile/user_profile.html b/frappe/desk/page/user_profile/user_profile.html index 911ccc702d..f134441b74 100644 --- a/frappe/desk/page/user_profile/user_profile.html +++ b/frappe/desk/page/user_profile/user_profile.html @@ -8,7 +8,7 @@
- No Data to Show + {%=__("No Data to Show") %}
@@ -19,7 +19,7 @@
- No Data to Show + {%=__("No Data to Show") %}
@@ -30,7 +30,7 @@
- No Data to Show + {%=__("No Data to Show") %}
@@ -41,4 +41,4 @@ - \ No newline at end of file + diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 6181261fc2..3c9109eca9 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -221,3 +221,37 @@ def validate_and_sanitize_search_inputs(fn, instance, args, kwargs): return [] return fn(**kwargs) + + +@frappe.whitelist() +def get_names_for_mentions(search_term): + users_for_mentions = frappe.cache().get_value('users_for_mentions', get_users_for_mentions) + user_groups = frappe.cache().get_value('user_groups', get_user_groups) + + filtered_mentions = [] + for mention_data in users_for_mentions + user_groups: + if search_term.lower() not in mention_data.value.lower(): + continue + + mention_data['link'] = frappe.utils.get_url_to_form( + 'User Group' if mention_data.get('is_group') else 'User Profile', + mention_data['id'] + ) + + filtered_mentions.append(mention_data) + + return sorted(filtered_mentions, key=lambda d: d['value']) + +def get_users_for_mentions(): + return frappe.get_all('User', + fields=['name as id', 'full_name as value'], + filters={ + 'name': ['not in', ('Administrator', 'Guest')], + 'allowed_in_mentions': True, + 'user_type': 'System User', + }) + +def get_user_groups(): + return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={ + 'is_group': True + }) diff --git a/frappe/event_streaming/doctype/event_producer/event_producer.py b/frappe/event_streaming/doctype/event_producer/event_producer.py index 3d97583549..4836276734 100644 --- a/frappe/event_streaming/doctype/event_producer/event_producer.py +++ b/frappe/event_streaming/doctype/event_producer/event_producer.py @@ -55,8 +55,8 @@ class EventProducer(Document): self.reload() def check_url(self): - if not frappe.utils.validate_url(self.producer_url): - frappe.throw(_('Invalid URL')) + valid_url_schemes = ("http", "https") + frappe.utils.validate_url(self.producer_url, throw=True, valid_schemes=valid_url_schemes) # remove '/' from the end of the url like http://test_site.com/ # to prevent mismatch in get_url() results diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index af06696621..205b451336 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -71,7 +71,8 @@ numeric_fieldtypes = ( data_field_options = ( 'Email', 'Name', - 'Phone' + 'Phone', + 'URL' ) default_fields = ( diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 983511f7a4..05435482bd 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -666,6 +666,12 @@ class BaseDocument(object): if data_field_options == "Phone": frappe.utils.validate_phone_number(data, throw=True) + if data_field_options == "URL": + if not data: + continue + + frappe.utils.validate_url(data, throw=True) + def _validate_constants(self): if frappe.flags.in_import or self.is_new() or self.flags.ignore_validate_constants: return diff --git a/frappe/oauth.py b/frappe/oauth.py index 3287bf7520..35f047a2b6 100644 --- a/frappe/oauth.py +++ b/frappe/oauth.py @@ -599,9 +599,10 @@ def get_client_scopes(client_id): def get_userinfo(user): picture = None frappe_server_url = get_server_url() + valid_url_schemes = ("http", "https", "ftp", "ftps") if user.user_image: - if frappe.utils.validate_url(user.user_image): + if frappe.utils.validate_url(user.user_image, valid_schemes=valid_url_schemes): picture = user.user_image else: picture = frappe_server_url + "/" + user.user_image diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 4bd2d53083..207483a164 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -114,8 +114,6 @@ frappe.Application = class Application { dialog.get_close_btn().toggle(false); }); - this.setup_user_group_listeners(); - // listen to build errors this.setup_build_events(); @@ -591,15 +589,6 @@ frappe.Application = class Application { } } - setup_user_group_listeners() { - frappe.realtime.on('user_group_added', (user_group) => { - frappe.boot.user_groups && frappe.boot.user_groups.push(user_group); - }); - frappe.realtime.on('user_group_deleted', (user_group) => { - frappe.boot.user_groups = (frappe.boot.user_groups || []).filter(el => el !== user_group); - }); - } - setup_energy_point_listeners() { frappe.realtime.on('energy_point_alert', (message) => { frappe.show_alert(message); @@ -609,8 +598,7 @@ frappe.Application = class Application { setup_copy_doc_listener() { $('body').on('paste', (e) => { try { - let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData; - let pasted_data = clipboard_data.getData('Text'); + let pasted_data = frappe.utils.get_clipboard_data(e); let doc = JSON.parse(pasted_data); if (doc.doctype) { e.preventDefault(); @@ -625,6 +613,7 @@ frappe.Application = class Application { let res = frappe.model.with_doctype(doc.doctype, () => { let newdoc = frappe.model.copy_doc(doc); newdoc.__newname = doc.name; + delete doc.name; newdoc.idx = null; newdoc.__run_link_triggers = false; frappe.set_route('Form', newdoc.doctype, newdoc.name); diff --git a/frappe/public/js/frappe/form/controls/color.js b/frappe/public/js/frappe/form/controls/color.js index 802aad371a..7e8e25fac9 100644 --- a/frappe/public/js/frappe/form/controls/color.js +++ b/frappe/public/js/frappe/form/controls/color.js @@ -1,11 +1,13 @@ import Picker from '../../color_picker/color_picker'; frappe.ui.form.ControlColor = class ControlColor extends frappe.ui.form.ControlData { - make_input () { + make_input() { + this.df.placeholder = this.df.placeholder || __('Choose a color'); super.make_input(); this.make_color_input(); } - make_color_input () { + + make_color_input() { let picker_wrapper = $('
'); this.picker = new Picker({ parent: picker_wrapper[0], @@ -48,7 +50,16 @@ frappe.ui.form.ControlColor = class ControlColor extends frappe.ui.form.ControlD $(window).off('hashchange.color-popover'); }); - this.$wrapper.find('.control-input').on('click', (e) => { + this.picker.on_change = (color) => { + this.set_value(color); + }; + + if (!this.selected_color) { + this.selected_color = $(`
`); + this.selected_color.insertAfter(this.$input); + } + + this.$wrapper.find('.selected-color').parent().on('click', (e) => { this.$wrapper.popover('toggle'); if (!this.get_color()) { this.$input.val(''); @@ -63,16 +74,8 @@ frappe.ui.form.ControlColor = class ControlColor extends frappe.ui.form.ControlD this.$wrapper.popover('hide'); }); }); - - this.picker.on_change = (color) => { - this.set_value(color); - }; - - if (!this.selected_color) { - this.selected_color = $(`
`); - this.selected_color.insertAfter(this.$input); - } } + refresh() { super.refresh(); let color = this.get_color(); @@ -81,19 +84,21 @@ frappe.ui.form.ControlColor = class ControlColor extends frappe.ui.form.ControlD this.picker.refresh(); } } + set_formatted_input(value) { super.set_formatted_input(value); - - this.$input.val(value || __('Choose a color')); + this.$input.val(value); this.selected_color.css({ "background-color": value || 'transparent', }); this.selected_color.toggleClass('no-value', !value); } + get_color() { return this.validate(this.get_value()); } - validate (value) { + + validate(value) { if (value === '') { return ''; } diff --git a/frappe/public/js/frappe/form/controls/comment.js b/frappe/public/js/frappe/form/controls/comment.js index 6d1664747f..7c10b61366 100644 --- a/frappe/public/js/frappe/form/controls/comment.js +++ b/frappe/public/js/frappe/form/controls/comment.js @@ -78,35 +78,25 @@ frappe.ui.form.ControlComment = class ControlComment extends frappe.ui.form.Cont } get_mention_options() { - if (!(this.mentions && this.mentions.length)) { + if (!this.enable_mentions) { return null; } - - const at_values = this.mentions.slice(); - + let me = this; return { allowedChars: /^[A-Za-z0-9_]*$/, mentionDenotationChars: ["@"], isolateCharacter: true, - source: function (searchTerm, renderList, mentionChar) { - let values; - - if (mentionChar === "@") { - values = at_values; - } - - if (searchTerm.length === 0) { - renderList(values, searchTerm); - } else { - const matches = []; - for (let i = 0; i < values.length; i++) { - if (~values[i].value.toLowerCase().indexOf(searchTerm.toLowerCase())) { - matches.push(values[i]); - } - } - renderList(matches, searchTerm); - } - }, + source: frappe.utils.debounce(async function(search_term, renderList) { + let method = me.mention_search_method || 'frappe.desk.search.get_names_for_mentions'; + let values = await frappe.xcall(method, { + search_term + }); + renderList(values, search_term); + }, 300), + renderItem(item) { + let value = item.value; + return `${value} ${item.is_group ? frappe.utils.icon('users') : ''}`; + } }; } diff --git a/frappe/public/js/frappe/form/controls/currency.js b/frappe/public/js/frappe/form/controls/currency.js index aa4df957db..0536a7403f 100644 --- a/frappe/public/js/frappe/form/controls/currency.js +++ b/frappe/public/js/frappe/form/controls/currency.js @@ -1,7 +1,7 @@ frappe.ui.form.ControlCurrency = class ControlCurrency extends frappe.ui.form.ControlFloat { format_for_input(value) { var formatted_value = format_number(value, this.get_number_format(), this.get_precision()); - return isNaN(parseFloat(value)) ? "" : formatted_value; + return isNaN(Number(value)) ? "" : formatted_value; } get_precision() { diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index dda0761694..772c8fb804 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -18,12 +18,99 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp this.$input.attr("maxlength", this.df.length || 140); } + this.$input.on('paste', (e) => { + let pasted_data = frappe.utils.get_clipboard_data(e); + let maxlength = this.$input.attr('maxlength'); + if (maxlength && pasted_data.length > maxlength) { + let warning_message = __('The value you pasted was {0} characters long. Max allowed characters is {1}.', [ + cstr(pasted_data.length).bold(), + cstr(maxlength).bold() + ]); + + // Only show edit link to users who can update the doctype + if (this.frm && frappe.model.can_write(this.frm.doctype)) { + let doctype_edit_link = null; + if (this.frm.meta.custom) { + doctype_edit_link = frappe.utils.get_form_link( + 'DocType', + this.frm.doctype, true, + __('this form') + ); + } else { + doctype_edit_link = frappe.utils.get_form_link('Customize Form', 'Customize Form', true, null, { + doc_type: this.frm.doctype + }); + } + let edit_note = __('{0}: You can increase the limit for the field if required via {1}', [ + __('Note').bold(), + doctype_edit_link + ]); + warning_message += `

${edit_note}`; + } + + frappe.msgprint({ + message: warning_message, + indicator: 'orange', + title: __('Data Clipped') + }); + } + }); + this.set_input_attributes(); this.input = this.$input.get(0); this.has_input = true; this.bind_change_event(); this.setup_autoname_check(); + + if (this.df.options == 'URL') { + this.setup_url_field(); + } } + + setup_url_field() { + this.$wrapper.find('.control-input').append( + ` + + ${frappe.utils.icon('link-url', 'sm')} + + ` + ); + + this.$link = this.$wrapper.find('.link-btn'); + this.$link_open = this.$link.find('.btn-open'); + + this.$input.on("focus", () => { + setTimeout(() => { + let inputValue = this.get_input_value(); + + if (inputValue && validate_url(inputValue)) { + this.$link.toggle(true); + this.$link_open.attr('href', this.get_input_value()); + } + }, 500); + }); + + + this.$input.bind("input", () => { + let inputValue = this.get_input_value(); + + if (inputValue && validate_url(inputValue)) { + this.$link.toggle(true); + this.$link_open.attr('href', this.get_input_value()); + } else { + this.$link.toggle(false); + } + }); + + this.$input.on("blur", () => { + // if this disappears immediately, the user's click + // does not register, hence timeout + setTimeout(() => { + this.$link.toggle(false); + }, 500); + }); + } + bind_change_event() { const change_handler = e => { if (this.change) this.change(e); @@ -126,6 +213,9 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp this.df.invalid = email_invalid; return v; } + } else if (this.df.options == 'URL') { + this.df.invalid = !validate_url(v); + return v; } else { return v; } diff --git a/frappe/public/js/frappe/form/controls/float.js b/frappe/public/js/frappe/form/controls/float.js index f6a40bd6f8..89f8f23cc5 100644 --- a/frappe/public/js/frappe/form/controls/float.js +++ b/frappe/public/js/frappe/form/controls/float.js @@ -10,7 +10,7 @@ frappe.ui.form.ControlFloat = class ControlFloat extends frappe.ui.form.ControlI number_format = this.get_number_format(); } var formatted_value = format_number(value, number_format, this.get_precision()); - return isNaN(parseFloat(value)) ? "" : formatted_value; + return isNaN(Number(value)) ? "" : formatted_value; } get_number_format() { diff --git a/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js b/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js index d6907158f9..4a3c2d2eba 100644 --- a/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js +++ b/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js @@ -12,6 +12,7 @@ class MentionBlot extends Embed { denotationChar.innerHTML = data.denotationChar; node.appendChild(denotationChar); node.innerHTML += data.value; + node.innerHTML += `${data.isGroup === 'true' ? frappe.utils.icon('users') : ''}`; node.dataset.id = data.id; node.dataset.value = data.value; node.dataset.denotationChar = data.denotationChar; diff --git a/frappe/public/js/frappe/form/controls/table.js b/frappe/public/js/frappe/form/controls/table.js index 92c824f1e5..35849a71a5 100644 --- a/frappe/public/js/frappe/form/controls/table.js +++ b/frappe/public/js/frappe/form/controls/table.js @@ -26,8 +26,7 @@ frappe.ui.form.ControlTable = class ControlTable extends frappe.ui.form.Control const row_docname = $(e.target).closest('.grid-row').data('name'); const in_grid_form = $(e.target).closest('.form-in-grid').length; - let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData; - let pasted_data = clipboard_data.getData('Text'); + let pasted_data = frappe.utils.get_clipboard_data(e); if (!pasted_data || in_grid_form) return; diff --git a/frappe/public/js/frappe/form/footer/footer.js b/frappe/public/js/frappe/form/footer/footer.js index aa29eea248..d6dfc227f0 100644 --- a/frappe/public/js/frappe/form/footer/footer.js +++ b/frappe/public/js/frappe/form/footer/footer.js @@ -24,7 +24,7 @@ frappe.ui.form.Footer = class FormFooter { parent: this.wrapper.find(".comment-box"), render_input: true, only_input: true, - mentions: frappe.utils.get_names_for_mentions(), + enable_mentions: true, df: { fieldtype: 'Comment', fieldname: 'comment' diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index bd64c504ca..ab83ed2f71 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -492,7 +492,7 @@ class FormTimeline extends BaseTimeline { fieldname: 'comment', label: 'Comment' }, - mentions: frappe.utils.get_names_for_mentions(), + enable_mentions: true, render_input: true, only_input: true, no_wrapper: true diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index 0b396ad35e..89c34ed80c 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -15,7 +15,10 @@ frappe.form.formatters = { return "
" + value + "
"; } }, - Data: function(value) { + Data: function(value, df) { + if (df && df.options == "URL") { + return `${value}`; + } return value==null ? "" : value; }, Select: function(value) { @@ -156,7 +159,7 @@ frappe.form.formatters = { return value || ""; }, DateRange: function(value) { - if($.isArray(value)) { + if (Array.isArray(value)) { return __("{0} to {1}", [frappe.datetime.str_to_user(value[0]), frappe.datetime.str_to_user(value[1])]); } else { return value || ""; @@ -292,12 +295,12 @@ frappe.form.formatters = { return formatted_values.join(', '); }, Color: (value) => { - return `
+ return value ? `
${value} -
`; +
` : ''; } -} +}; frappe.form.get_formatter = function(fieldtype) { if(!fieldtype) diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 9a689fabf4..f6da88df57 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -422,7 +422,7 @@ export default class GridRow { field.$input .addClass('input-sm') .attr('data-col-idx', column.column_index) - .attr('placeholder', __(df.label)); + .attr('placeholder', __(df.placeholder || df.label)); // flag list input if (this.columns_list && this.columns_list.slice(-1)[0]===column) { field.$input.attr('data-last-input', 1); diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index 22787b70c1..c93466e024 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -32,7 +32,7 @@ frappe.ui.form.Toolbar = class Toolbar { } set_title() { if (this.frm.is_new()) { - var title = __('New {0}', [this.frm.meta.name]); + var title = __('New {0}', [__(this.frm.meta.name)]); } else if (this.frm.meta.title_field) { let title_field = (this.frm.doc[this.frm.meta.title_field] || "").toString().trim(); var title = strip_html(title_field || this.frm.docname); @@ -551,7 +551,7 @@ frappe.ui.form.Toolbar = class Toolbar { let fields = this.frm.fields .filter(visible_fields_filter) - .map(f => ({ label: f.df.label, value: f.df.fieldname })); + .map(f => ({ label: __(f.df.label), value: f.df.fieldname })); let dialog = new frappe.ui.Dialog({ title: __('Jump to field'), diff --git a/frappe/public/js/frappe/list/list_view_select.js b/frappe/public/js/frappe/list/list_view_select.js index 9607be6e90..c89815d200 100644 --- a/frappe/public/js/frappe/list/list_view_select.js +++ b/frappe/public/js/frappe/list/list_view_select.js @@ -150,13 +150,13 @@ frappe.views.ListViewSelect = class ListViewSelect { const views_wrapper = this.sidebar.sidebar.find(".views-section"); views_wrapper.find(".sidebar-label").html(`${__(view)}`); const $dropdown = views_wrapper.find(".views-dropdown"); - - let placeholder = `Select ${view}`; + + let placeholder = `${__("Select {0}", [__(view)])}`; let html = ``; if (!items || !items.length) { html = `
- ${__("No {} Found", [view])} + ${__("No {0} Found", [__(view)])}
`; } else { const page_name = this.get_page_name(); diff --git a/frappe/public/js/frappe/recorder/RecorderDetail.vue b/frappe/public/js/frappe/recorder/RecorderDetail.vue index 57e63a0233..d17a8f0ec4 100644 --- a/frappe/public/js/frappe/recorder/RecorderDetail.vue +++ b/frappe/public/js/frappe/recorder/RecorderDetail.vue @@ -5,7 +5,7 @@
@@ -71,12 +71,12 @@
-

Recorder is Inactive

-

+

{{ __("Recorder is Inactive") }}

+

-

No Requests found

-

Go make some noise

+

{{ __("No Requests found") }}

+

{{ __("Go make some noise") }}

@@ -108,12 +108,12 @@ export default { return { requests: [], columns: [ - {label: "Path", slug: "path"}, - {label: "Duration (ms)", slug: "duration", sortable: true, number: true}, - {label: "Time in Queries (ms)", slug: "time_queries", sortable: true, number: true}, - {label: "Queries", slug: "queries", sortable: true, number: true}, - {label: "Method", slug: "method"}, - {label: "Time", slug: "time", sortable: true}, + {label: __("Path"), slug: "path"}, + {label: __("Duration (ms)"), slug: "duration", sortable: true, number: true}, + {label: __("Time in Queries (ms)"), slug: "time_queries", sortable: true, number: true}, + {label: __("Queries"), slug: "queries", sortable: true, number: true}, + {label: __("Method"), slug: "method"}, + {label: __("Time"), slug: "time", sortable: true}, ], query: { sort: "duration", @@ -140,7 +140,7 @@ export default { mounted() { this.fetch_status(); this.refresh(); - this.$root.page.set_secondary_action("Clear", () => { + this.$root.page.set_secondary_action(__("Clear"), () => { frappe.set_route("recorder"); this.clear(); }); @@ -151,11 +151,11 @@ export default { const current_page = this.query.pagination.page; const total_pages = this.query.pagination.total; return [{ - label: "First", + label: __("First"), number: 1, status: (current_page == 1) ? "disabled" : "", },{ - label: "Previous", + label: __("Previous"), number: Math.max(current_page - 1, 1), status: (current_page == 1) ? "disabled" : "", }, { @@ -163,11 +163,11 @@ export default { number: current_page, status: "btn-info", }, { - label: "Next", + label: __("Next"), number: Math.min(current_page + 1, total_pages), status: (current_page == total_pages) ? "disabled" : "", }, { - label: "Last", + label: __("Last"), number: total_pages, status: (current_page == total_pages) ? "disabled" : "", }]; @@ -230,11 +230,11 @@ export default { }, update_buttons: function() { if(this.status.status == "Active") { - this.$root.page.set_primary_action("Stop", () => { + this.$root.page.set_primary_action(__("Stop"), () => { this.stop(); }); } else { - this.$root.page.set_primary_action("Start", () => { + this.$root.page.set_primary_action(__("Start"), () => { this.start(); }); } diff --git a/frappe/public/js/frappe/recorder/RequestDetail.vue b/frappe/public/js/frappe/recorder/RequestDetail.vue index cc056686d5..2e995bca39 100644 --- a/frappe/public/js/frappe/recorder/RequestDetail.vue +++ b/frappe/public/js/frappe/recorder/RequestDetail.vue @@ -16,7 +16,7 @@
-
SQL Queries
+
{{ __("SQL Queries") }}
@@ -37,7 +37,7 @@
@@ -48,15 +48,15 @@
- Index
+ {{ __("Index") }}
-
Query
+
{{ __("Query") }}
-
Duration (ms)
+
{{ __("Duration (ms)") }}
-
Exact Copies
+
{{ __("Exact Copies") }}
@@ -82,7 +82,7 @@
- SQL Query #{{ call.index }} + {{ __("SQL Query") }} #{{ call.index }}
@@ -98,25 +98,25 @@
-
+
-
+
-
+
-
+