Merge remote-tracking branch 'upstream/develop' into esbuild

This commit is contained in:
Faris Ansari 2021-05-04 07:13:30 +05:30
commit 6347d3e6f1
69 changed files with 928 additions and 257 deletions

View file

@ -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,

View file

@ -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
};

View file

@ -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');
});
});
});

View file

@ -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');
});
});
});

View file

@ -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)

View file

@ -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)

View file

@ -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))

View file

@ -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

View file

@ -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,

View file

@ -662,4 +662,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View file

@ -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()

View file

@ -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')

View file

@ -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
});

View file

@ -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
}
}

View file

@ -5,6 +5,7 @@
.download-backup-card {
display: block;
text-decoration: none;
margin-bottom: var(--margin-lg);
}
.download-backup-card:hover {

View file

@ -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
});

View file

@ -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,
});

View file

@ -8,7 +8,7 @@
</div>
<div class="chart-wrapper performance-heatmap">
<div class="null-state">
<span>No Data to Show</span>
<span>{%=__("No Data to Show") %}</span>
</div>
</div>
</div>
@ -19,7 +19,7 @@
</div>
<div class="chart-wrapper performance-percentage-chart">
<div class="null-state">
<span>No Data to Show</span>
<span>{%=__("No Data to Show") %}</span>
</div>
</div>
</div>
@ -30,7 +30,7 @@
</div>
<div class="chart-wrapper performance-line-chart">
<div class="null-state">
<span>No Data to Show</span>
<span>{%=__("No Data to Show") %}</span>
</div>
</div>
</div>
@ -41,4 +41,4 @@
<div class="recent-activity-footer"></div>
</div>
</div>
</div>
</div>

View file

@ -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
})

View file

@ -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

View file

@ -71,7 +71,8 @@ numeric_fieldtypes = (
data_field_options = (
'Email',
'Name',
'Phone'
'Phone',
'URL'
)
default_fields = (

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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 = $('<div>');
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 = $(`<div class="selected-color"></div>`);
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 = $(`<div class="selected-color"></div>`);
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 '';
}

View file

@ -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') : ''}`;
}
};
}

View file

@ -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() {

View file

@ -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 += `<br><br><span class="text-muted text-small">${edit_note}</span>`;
}
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(
`<span class="link-btn">
<a class="btn-open no-decoration" title="${__("Open Link")}" target="_blank">
${frappe.utils.icon('link-url', 'sm')}
</a>
</span>`
);
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;
}

View file

@ -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() {

View file

@ -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;

View file

@ -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;

View file

@ -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'

View file

@ -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

View file

@ -15,7 +15,10 @@ frappe.form.formatters = {
return "<div style='text-align: right'>" + value + "</div>";
}
},
Data: function(value) {
Data: function(value, df) {
if (df && df.options == "URL") {
return `<a href="${value}" title="Open Link" target="_blank">${value}</a>`;
}
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 `<div>
return value ? `<div>
<div class="selected-color" style="background-color: ${value}"></div>
<span class="color-value">${value}</span>
</div>`;
</div>` : '';
}
}
};
frappe.form.get_formatter = function(fieldtype) {
if(!fieldtype)

View file

@ -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);

View file

@ -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'),

View file

@ -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 = `<div class="empty-state">
${__("No {} Found", [view])}
${__("No {0} Found", [__(view)])}
</div>`;
} else {
const page_name = this.get_page_name();

View file

@ -5,7 +5,7 @@
<div class="tag-filters-area">
<div class="active-tag-filters">
<button class="btn btn-default btn-xs add-filter text-muted">
Add Filter
{{ __("Add Filter") }}
</button>
</div>
</div>
@ -71,12 +71,12 @@
</div>
<div v-if="requests.length == 0" class="no-result text-muted flex justify-center align-center" style="">
<div class="msg-box no-border" v-if="status.status == 'Inactive'" >
<p>Recorder is Inactive</p>
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">Start Recording</button></p>
<p>{{ __("Recorder is Inactive") }}</p>
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">{{ __("Start Recording") }}</button></p>
</div>
<div class="msg-box no-border" v-if="status.status == 'Active'" >
<p>No Requests found</p>
<p>Go make some noise</p>
<p>{{ __("No Requests found") }}</p>
<p>{{ __("Go make some noise") }}</p>
</div>
</div>
<div v-if="requests.length != 0" class="list-paging-area">
@ -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();
});
}

View file

@ -16,7 +16,7 @@
</div>
<div class="row form-section visible-section">
<div class="col-sm-10">
<h6 class="form-section-heading uppercase">SQL Queries</h6>
<h6 class="form-section-heading uppercase">{{ __("SQL Queries") }}</h6>
</div>
<div class="col-sm-2 filter-list">
<div class="sort-selector">
@ -37,7 +37,7 @@
<div class="checkbox">
<label>
<span class="input-area"><input type="checkbox" class="input-with-feedback bold" data-fieldtype="Check" v-model="group_duplicates"></span>
<span class="label-area small">Group Duplicate Queries</span>
<span class="label-area small">{{ __("Group Duplicate Queries") }}</span>
</label>
</div>
</div>
@ -48,15 +48,15 @@
<div class="grid-row">
<div class="data-row row">
<div class="row-index col col-xs-1">
<span>Index</span></div>
<span>{{ __("Index") }}</span></div>
<div class="col grid-static-col col-xs-6">
<div class="static-area ellipsis">Query</div>
<div class="static-area ellipsis">{{ __("Query") }}</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">Duration (ms)</div>
<div class="static-area ellipsis text-right">{{ __("Duration (ms)") }}</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">Exact Copies</div>
<div class="static-area ellipsis text-right">{{ __("Exact Copies") }}</div>
</div>
</div>
</div>
@ -82,7 +82,7 @@
<div class="recorder-form-in-grid" v-if="showing == call.index">
<div class="grid-form-heading" @click="showing = null">
<div class="toolbar grid-header-toolbar">
<span class="panel-title">SQL Query #<span class="grid-form-row-index">{{ call.index }}</span></span>
<span class="panel-title">{{ __("SQL Query") }} #<span class="grid-form-row-index">{{ call.index }}</span></span>
<div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;">
<span class="hidden-xs octicon octicon-triangle-up"></span>
</div>
@ -98,25 +98,25 @@
<form>
<div class="frappe-control">
<div class="form-group">
<div class="clearfix"><label class="control-label">Query</label></div>
<div class="clearfix"><label class="control-label">{{ __("Query") }}</label></div>
<div class="control-value like-disabled-input for-description"><pre>{{ call.query }}</pre></div>
</div>
</div>
<div class="frappe-control input-max-width">
<div class="form-group">
<div class="clearfix"><label class="control-label">Duration (ms)</label></div>
<div class="clearfix"><label class="control-label">{{ __("Duration (ms)") }}</label></div>
<div class="control-value like-disabled-input">{{ call.duration }}</div>
</div>
</div>
<div class="frappe-control input-max-width">
<div class="form-group">
<div class="clearfix"><label class="control-label">Exact Copies</label></div>
<div class="clearfix"><label class="control-label">{{ __("Exact Copies") }}</label></div>
<div class="control-value like-disabled-input">{{ call.exact_copies }}</div>
</div>
</div>
<div class="frappe-control">
<div class="form-group">
<div class="clearfix"><label class="control-label">Stack Trace</label></div>
<div class="clearfix"><label class="control-label"{{ __("Stack Trace") }}</label></div>
<div class="control-value like-disabled-input for-description" style="overflow:auto">
<table class="table table-striped">
<thead>
@ -137,7 +137,7 @@
</div>
<div class="frappe-control" v-if="call.explain_result[0]">
<div class="form-group">
<div class="clearfix"><label class="control-label">SQL Explain</label></div>
<div class="clearfix"><label class="control-label">{{ __("SQL Explain") }}</label></div>
<div class="control-value like-disabled-input for-description" style="overflow:auto">
<table class="table table-striped">
<thead>
@ -165,7 +165,7 @@
</div>
</div>
</div>
<div v-if="request.calls.length == 0" class="grid-empty text-center">No Data</div>
<div v-if="request.calls.length == 0" class="grid-empty text-center">{{ __("No Data") }}</div>
</div>
</div>
</div>
@ -201,19 +201,19 @@ export default {
data() {
return {
columns: [
{label: "Path", slug: "path", type: "Data", class: "col-sm-6"},
{label: "CMD", slug: "cmd", type: "Data", class: "col-sm-6"},
{label: "Time", slug: "time", type: "Time", class: "col-sm-6"},
{label: "Duration (ms)", slug: "duration", type: "Float", class: "col-sm-6"},
{label: "Number of Queries", slug: "queries", type: "Int", class: "col-sm-6"},
{label: "Time in Queries (ms)", slug: "time_queries", type: "Float", class: "col-sm-6"},
{label: "Request Headers", slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: "Form Dict", slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: __("Path"), slug: "path", type: "Data", class: "col-sm-6"},
{label: __("CMD"), slug: "cmd", type: "Data", class: "col-sm-6"},
{label: __("Time"), slug: "time", type: "Time", class: "col-sm-6"},
{label: __("Duration (ms)"), slug: "duration", type: "Float", class: "col-sm-6"},
{label: __("Number of Queries"), slug: "queries", type: "Int", class: "col-sm-6"},
{label: __("Time in Queries (ms)"), slug: "time_queries", type: "Float", class: "col-sm-6"},
{label: __("Request Headers"), slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: __("Form Dict"), slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
],
table_columns: [
{label: "Execution Order", slug: "index", sortable: true},
{label: "Duration (ms)", slug: "duration", sortable: true},
{label: "Exact Copies", slug: "exact_copies", sortable: true},
{label: __("Execution Order"), slug: "index", sortable: true},
{label: __("Duration (ms)"), slug: "duration", sortable: true},
{label: __("Exact Copies"), slug: "exact_copies", sortable: true},
],
query: {
sort: "duration",
@ -236,11 +236,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" : "",
}, {
@ -248,11 +248,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" : "",
}];

View file

@ -6,6 +6,9 @@ import RecorderRoot from "./RecorderRoot.vue";
import RecorderDetail from "./RecorderDetail.vue";
import RequestDetail from "./RequestDetail.vue";
Vue.prototype.__ = window.__;
Vue.prototype.frappe = window.frappe;
Vue.use(VueRouter);
const routes = [
{

View file

@ -312,7 +312,7 @@ class NotificationsView extends BaseNotificationsView {
this.container.append($(`<div class="notification-null-state">
<div class="text-center">
<img src="/assets/frappe/images/ui-states/notification-empty-state.svg" alt="Generic Empty State" class="null-state">
<div class="title">No New notifications</div>
<div class="title">${__('No New notifications')}</div>
<div class="subtitle">
${__('Looks like you havent received any notifications.')}
</div></div></div>`));
@ -430,7 +430,7 @@ class EventsView extends BaseNotificationsView {
<div class="notification-null-state">
<div class="text-center">
<img src="/assets/frappe/images/ui-states/event-empty-state.svg" alt="Generic Empty State" class="null-state">
<div class="title">No Upcoming Events</div>
<div class="title">${__('No Upcoming Events')}</div>
<div class="subtitle">
${__('There are no upcoming events for you.')}
</div></div></div>

View file

@ -11,6 +11,34 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
title: __("Switch Theme")
});
this.body = $(`<div class="theme-grid"></div>`).appendTo(this.dialog.$body);
this.bind_events();
}
bind_events() {
this.dialog.$wrapper.on('keydown', (e) => {
if (!this.themes) return;
const key = frappe.ui.keys.get_key(e);
let increment_by;
if (key === "right") {
increment_by = 1;
} else if (key === "left") {
increment_by = -1;
} else {
return;
}
const current_index = this.themes.findIndex(theme => {
return theme.name === this.current_theme;
});
const new_theme = this.themes[current_index + increment_by];
if (!new_theme) return;
new_theme.$html.click();
return false;
});
}
refresh() {

View file

@ -52,6 +52,10 @@ window.validate_name = function(txt) {
return frappe.utils.validate_type(txt, "name");
};
window.validate_url = function(txt) {
return frappe.utils.validate_type(txt, "url");
};
window.nth = function(number) {
number = cint(number);
var s = 'th';

View file

@ -405,7 +405,7 @@ Object.assign(frappe.utils, {
regExp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
break;
case "url":
regExp = /^(https?|s?ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!$&'()*+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[$&'()*+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!$&'()*+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!$&'()*+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!$&'()*+,;=]|:|@)|\/|\?)*)?$/i;
regExp = /^((([A-Za-z0-9.+-]+:(?:\/\/)?)(?:[-;:&=\+\,\w]@)?[A-Za-z0-9.-]+(:[0-9]+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)$/i;
break;
case "dateIso":
regExp = /^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])$/;
@ -831,10 +831,13 @@ Object.assign(frappe.utils, {
if (callNow) func.apply(context, args);
};
},
get_form_link: function(doctype, name, html = false, display_text = null) {
get_form_link: function(doctype, name, html=false, display_text=null, query_params_obj=null) {
display_text = display_text || name;
name = encodeURIComponent(name);
const route = `/app/${encodeURIComponent(doctype.toLowerCase().replace(/ /g, '-'))}/${name}`;
let route = `/app/${encodeURIComponent(doctype.toLowerCase().replace(/ /g, '-'))}/${name}`;
if (query_params_obj) {
route += frappe.utils.make_query_string(query_params_obj);
}
if (html) {
return `<a href="${route}">${display_text}</a>`;
}
@ -1286,31 +1289,6 @@ Object.assign(frappe.utils, {
</div>`);
},
get_names_for_mentions() {
let names_for_mentions = Object.keys(frappe.boot.user_info || [])
.filter(user => {
return !["Administrator", "Guest"].includes(user)
&& frappe.boot.user_info[user].allowed_in_mentions
&& frappe.boot.user_info[user].user_type === 'System User';
})
.map(user => {
return {
id: frappe.boot.user_info[user].name,
value: frappe.boot.user_info[user].fullname,
};
});
frappe.boot.user_groups && frappe.boot.user_groups.map(group => {
names_for_mentions.push({
id: group,
value: group,
is_group: true,
link: frappe.utils.get_form_link('User Group', group)
});
});
return names_for_mentions;
},
print(doctype, docname, print_format, letterhead, lang_code) {
let w = window.open(
frappe.urllib.get_full_url(
@ -1333,5 +1311,11 @@ Object.assign(frappe.utils, {
frappe.msgprint(__('Please enable pop-ups'));
return;
}
},
get_clipboard_data(clipboard_paste_event) {
let e = clipboard_paste_event;
let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData;
return clipboard_data.getData('Text');
}
});

View file

@ -68,6 +68,8 @@ frappe.breadcrumbs = {
if (breadcrumbs.doctype && ["print", "form"].includes(view)) {
this.set_list_breadcrumb(breadcrumbs);
this.set_form_breadcrumb(breadcrumbs, view);
} else if (breadcrumbs.doctype && view === 'list') {
this.set_list_breadcrumb(breadcrumbs);
}
}

View file

@ -8,7 +8,7 @@ frappe.views.DashboardView = class DashboardView extends frappe.views.ListView {
setup_defaults() {
return super.setup_defaults()
.then(() => {
this.page_title = __('{0} Dashboard', [this.doctype]);
this.page_title = __('{0} Dashboard', [__(this.doctype)]);
this.dashboard_settings = frappe.get_user_settings(this.doctype)['dashboard_settings'] || null;
});
}
@ -271,7 +271,7 @@ frappe.views.DashboardView = class DashboardView extends frappe.views.ListView {
show_add_chart_dialog() {
let fields = this.get_field_options();
const dialog = new frappe.ui.Dialog({
title: __("Add a {0} Chart", [this.doctype]),
title: __("Add a {0} Chart", [__(this.doctype)]),
fields: [
{
fieldname: 'new_or_existing',

View file

@ -335,12 +335,12 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
let message;
if (dashboard_name) {
let dashboard_route_html = `<a href="#dashboard-view/${dashboard_name}">${dashboard_name}</a>`;
message = __("New {0} {1} added to Dashboard {2}", [doctype, name, dashboard_route_html]);
message = __("New {0} {1} added to Dashboard {2}", [__(doctype), name, dashboard_route_html]);
} else {
message = __("New {0} {1} created", [doctype, name]);
message = __("New {0} {1} created", [__(doctype), name]);
}
frappe.msgprint(message, __("New {0} Created", [doctype]));
frappe.msgprint(message, __("New {0} Created", [__(doctype)]));
});
}
@ -937,7 +937,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
else {
wrapper[0].innerHTML =
`<div class="flex justify-center align-center text-muted" style="height: 120px; display: flex;">
<div>Please select X and Y fields</div>
<div>${__("Please select X and Y fields")}</div>
</div>`;
}
}
@ -1094,7 +1094,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
return Object.assign(column, {
id: column.fieldname,
name: __(column.label),
name: __(column.label, null, `Column of report '${this.report_name}'`), // context has to match context in get_messages_from_report in translate.py
width: parseInt(column.width) || null,
editable: false,
compareValue: compareFn,
@ -1343,7 +1343,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
open_url_post(frappe.request.url, args);
}
}, __('Export Report: '+ this.report_name), __('Download'));
}, __('Export Report: {0}', [this.report_name]), __('Download'));
}
get_data_for_csv(include_indentation) {

View file

@ -2,7 +2,6 @@ import Widget from "./base_widget.js";
frappe.provide("frappe.utils");
const INDICATOR_COLORS = ["Grey", "Green", "Red", "Orange", "Pink", "Yellow", "Blue", "Cyan", "Teal"];
export default class ShortcutWidget extends Widget {
constructor(opts) {
opts.shadow = true;
@ -79,7 +78,7 @@ export default class ShortcutWidget extends Widget {
this.action_area.empty();
const label = get_label();
let color = INDICATOR_COLORS.includes(this.color) && count ? this.color.toLowerCase() : 'gray';
let color = this.color && count ? this.color.toLowerCase() : 'gray';
$(`<div class="indicator-pill ellipsis ${color}">${label}</div>`).appendTo(this.action_area);
}
}

View file

@ -237,9 +237,19 @@ class ShortcutDialog extends WidgetDialog {
hidden: 1,
},
{
fieldtype: "Color",
fieldtype: "Select",
fieldname: "color",
label: __("Color"),
options: ["Grey", "Green", "Red", "Orange", "Pink", "Yellow", "Blue", "Cyan"],
default: "Grey",
onchange: () => {
let color = this.dialog.fields_dict.color.value.toLowerCase();
let $select = this.dialog.fields_dict.color.$input;
if (!$select.parent().find('.color-box').get(0)) {
$(`<div class="color-box"></div>`).insertBefore($select.get(0));
}
$select.parent().find('.color-box').get(0).style.backgroundColor = `var(--text-on-${color})`;
}
},
{
fieldtype: "Column Break",

View file

@ -121,3 +121,10 @@
}
}
}
.data-row.row {
.selected-color {
top: calc(50% - 11px);
z-index: 2;
}
}

View file

@ -24,6 +24,17 @@
--blue-100: #D3E9FC;
--blue-50 : #F0F8FE;
--cyan-900: #006464;
--cyan-800: #007272;
--cyan-700: #008b8b;
--cyan-600: #02c5c5;
--cyan-500: #00ffff;
--cyan-400: #2ef8f8;
--cyan-300: #6efcfc;
--cyan-200: #a0f8f8;
--cyan-100: #c7fcfc;
--cyan-50 : #dafafa;
--green-900: #2D401D;
--green-800: #44622A;
--green-700: #518B21;
@ -151,6 +162,8 @@
--bg-gray: var(--gray-200);
--bg-light-gray: var(--gray-100);
--bg-purple: var(--purple-100);
--bg-pink: var(--pink-50);
--bg-cyan: var(--cyan-50);
--text-on-blue: var(--blue-600);
--text-on-light-blue: var(--blue-500);
@ -163,6 +176,8 @@
--text-on-gray: var(--gray-600);
--text-on-light-gray: var(--gray-800);
--text-on-purple: var(--purple-500);
--text-on-pink: var(--pink-500);
--text-on-cyan: var(--cyan-600);
--awesomplete-hover-bg: var(--control-bg);

View file

@ -16,73 +16,82 @@
}
}
$check-icon: url("data:image/svg+xml, <svg viewBox='0 0 8 7' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M1 4.00001L2.66667 5.80001L7 1.20001' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>");
input[type="checkbox"] {
position: relative;
width: 0 !important;
height: var(--custom-checkbox-size);
margin-right: calc(var(--custom-checkbox-size) + var(--checkbox-right-margin)) !important;
font-size: calc(var(--custom-checkbox-size) - 1px);
width: var(--checkbox-size) !important;
height: var(--checkbox-size);
margin-right: var(--checkbox-right-margin) !important;
background-repeat: no-repeat;
background-position: center;
border: 1px solid var(--gray-400);
box-sizing: border-box;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);
border-radius: 4px;
&:before {
width: var(--custom-checkbox-size);
height: var(--custom-checkbox-size);
position: absolute;
top: 0;
display: inline-block;
line-height: 1;
text-align: center;
content: ' ';
border: 1px solid var(--gray-400);
box-sizing: border-box;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);
border-radius: 4px;
// Reset browser behavior
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
-webkit-print-color-adjust: exact;
color-adjust: exact;
.grid-static-col & {
margin-right: 0 !important;
}
&:checked:before {
content: url("data: image/svg+xml;utf8, <svg width='8' height='7' viewBox='0 0 8 7' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M1 4.00001L2.66667 5.80001L7 1.20001' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>");
background: linear-gradient(180deg, #4AC3F8 -124.51%, #2490EF 100%);
&:checked {
background-color: #2490EF;
background-image: $check-icon, linear-gradient(180deg, #4AC3F8 -124.51%, #2490EF 100%);
background-size: 57%, 100%;
box-shadow: none;
border: none;
}
&:focus {
outline: none; // Prevent browser behavior
box-shadow: var(--checkbox-focus-shadow);
}
&.disabled-deselected:before, &:disabled:not([checked])::before {
background: var(--disabled-control-bg);
border: 0.5px solid var(--gray-300);
box-sizing: border-box;
&.disabled-deselected, &:disabled {
background-color: var(--disabled-control-bg);
box-shadow: inset 0px 1px 7px rgba(0, 0, 0, 0.1);
border-radius: 4px;
border: 0.5px solid var(--gray-300);
pointer-events: none;
}
&.disabled-selected:before, &:disabled:checked::before {
content: url("data: image/svg+xml;utf8, <svg width='8' height='7' viewBox='0 0 8 7' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M1 4.00001L2.66667 5.80001L7 1.20001' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>");
background: var(--gray-500);
box-sizing: border-box;
&.disabled-selected, &:disabled:checked {
background-color: var(--gray-500);
background-image: $check-icon;
background-size: 57%;
box-shadow: inset 0px 1px 3px rgba(0, 0, 0, 0.1);
border-radius: 4px;
line-height: 10px;
border: none;
pointer-events: none;
}
}
// Firefox doesn't support
// pseudo elements on checkbox
html.firefox, html.safari {
:root {
--custom-checkbox-size: 0px;
}
input[type="checkbox"] {
width: var(--base-checkbox-size) !important;
height: var(--base-checkbox-size);
margin-right: var(--checkbox-right-margin) !important;
}
}
.frappe-card {
@include card();
}
.frappe-control[data-fieldtype="Select"].frappe-control[data-fieldname="color"] {
select {
padding-left: 40px;
}
.color-box {
position: absolute;
top: calc(50% - 11px);
left: 8px;
width: 22px;
height: 22px;
border-radius: 5px;
z-index: 1;
}
}
.frappe-control[data-fieldtype="Select"] .control-input,
.frappe-control[data-fieldtype="Select"].form-group {
position: relative;

View file

@ -196,7 +196,7 @@
margin-left: 1rem;
}
.grid-static-col[data-fieldtype="Code"] {
.grid-static-col[data-fieldtype="Code"], .grid-static-col[data-fieldtype="HTML Editor"] {
overflow: hidden;
.static-area {

View file

@ -77,6 +77,16 @@
@include indicator-pill-color('green');
}
.indicator.cyan {
@include indicator-color('cyan');
}
.indicator-pill.cyan,
.indicator-pill-right.cyan,
.indicator-pill-round.cyan {
@include indicator-pill-color('cyan');
}
.indicator.blue {
@include indicator-color('blue');
}
@ -131,6 +141,16 @@
@include indicator-pill-color('red');
}
.indicator.pink {
@include indicator-color('pink');
}
.indicator-pill.pink,
.indicator-pill-right.pink,
.indicator-pill-round.pink {
@include indicator-pill-color('pink');
}
.indicator-pill.darkgrey,
.indicator-pill-right.darkgrey,
.indicator-pill-round.darkgrey {

View file

@ -105,6 +105,7 @@
padding: 10px 12px;
height: initial;
line-height: initial;
cursor: pointer;
&.selected {
background-color: var(--control-bg);
@ -196,5 +197,8 @@
}
.mention[data-is-group="true"] {
background-color: var(--group-mention-bg-color);
.icon {
margin-top: -2px;
margin-left: 4px;
}
}

View file

@ -31,9 +31,6 @@ $input-height: 28px !default;
--modal-bg: white;
--toast-bg: var(--modal-bg);
--popover-bg: white;
--checkbox-right-margin: var(--margin-xs);
--base-checkbox-size: 14px;
--custom-checkbox-size: 14px;
--appreciation-color: var(--dark-green-600);
--appreciation-bg: var(--dark-green-100);
@ -52,6 +49,11 @@ $input-height: 28px !default;
--input-height: #{$input-height};
--input-disabled-bg: var(--gray-200);
// checkbox
--checkbox-right-margin: var(--margin-xs);
--checkbox-size: 14px;
--checkbox-focus-shadow: 0 0 0 2px var(--gray-300);
// timeline
--timeline-item-icon-size: 34px;
--timeline-item-left-margin: var(--margin-xl);

View file

@ -78,6 +78,9 @@
// input
--input-disabled-bg: none;
// checkbox
--checkbox-focus-shadow: 0 0 0 2px var(--gray-600);
color-scheme: dark;
.frappe-card {

View file

@ -197,8 +197,7 @@ $level-margin-right: 8px;
input.list-check-all, input.list-row-checkbox {
margin-top: 0px;
margin-left: calc(var(--custom-checkbox-size) / 2);
--checkbox-right-margin: #{$level-margin-right};
--checkbox-right-margin: calc(var(--checkbox-size) / 2 + #{$level-margin-right});
}
.filterable {

View file

@ -365,6 +365,17 @@ class TestCommands(BaseTestCommands):
installed_apps = set(frappe.get_installed_apps())
self.assertSetEqual(list_apps, installed_apps)
# test 3: parse json format
self.execute("bench --site all list-apps --format json")
self.assertEquals(self.returncode, 0)
self.assertIsInstance(json.loads(self.stdout), dict)
self.execute("bench --site {site} list-apps --format json")
self.assertIsInstance(json.loads(self.stdout), dict)
self.execute("bench --site {site} list-apps -f json")
self.assertIsInstance(json.loads(self.stdout), dict)
def test_get_bench_relative_path(self):
bench_path = frappe.utils.get_bench_path()
test1_path = os.path.join(bench_path, "test1.txt")

View file

@ -3,8 +3,10 @@
from __future__ import unicode_literals
import unittest
import frappe
from frappe.utils import evaluate_filters, money_in_words, scrub_urls, get_url
from frappe.utils import validate_url, validate_email_address
from frappe.utils import ceil, floor
from PIL import Image
@ -54,14 +56,15 @@ class TestMoney(unittest.TestCase):
for num in nums_bhd:
self.assertEqual(
money_in_words(num[0], "BHD"), num[1], "{0} is not the same as {1}".
format(money_in_words(num[0], "BHD"), num[1])
money_in_words(num[0], "BHD"),
num[1],
"{0} is not the same as {1}".format(money_in_words(num[0], "BHD"), num[1])
)
for num in nums_ngn:
self.assertEqual(
money_in_words(num[0], "NGN"), num[1], "{0} is not the same as {1}".
format(money_in_words(num[0], "NGN"), num[1])
money_in_words(num[0], "NGN"), num[1],
"{0} is not the same as {1}".format(money_in_words(num[0], "NGN"), num[1])
)
class TestDataManipulation(unittest.TestCase):
@ -93,7 +96,7 @@ class TestDataManipulation(unittest.TestCase):
class TestMathUtils(unittest.TestCase):
def test_floor(self):
from decimal import Decimal
self.assertEqual(floor(2), 2 )
self.assertEqual(floor(2), 2)
self.assertEqual(floor(12.32904), 12)
self.assertEqual(floor(22.7330), 22)
self.assertEqual(floor('24.7'), 24)
@ -102,7 +105,7 @@ class TestMathUtils(unittest.TestCase):
def test_ceil(self):
from decimal import Decimal
self.assertEqual(ceil(2), 2 )
self.assertEqual(ceil(2), 2)
self.assertEqual(ceil(12.32904), 13)
self.assertEqual(ceil(22.7330), 23)
self.assertEqual(ceil('24.7'), 25)
@ -127,6 +130,56 @@ class TestHTMLUtils(unittest.TestCase):
self.assertTrue('<h1>Hello</h1>' in clean)
self.assertTrue('<a href="http://test.com">text</a>' in clean)
class TestValidationUtils(unittest.TestCase):
def test_valid_url(self):
# Edge cases
self.assertFalse(validate_url(''))
self.assertFalse(validate_url(None))
# Valid URLs
self.assertTrue(validate_url('https://google.com'))
self.assertTrue(validate_url('http://frappe.io', throw=True))
# Invalid URLs without throw
self.assertFalse(validate_url('google.io'))
self.assertFalse(validate_url('google.io'))
# Invalid URL with throw
self.assertRaises(frappe.ValidationError, validate_url, 'frappe', throw=True)
# Scheme validation
self.assertFalse(validate_url('https://google.com', valid_schemes='http'))
self.assertTrue(validate_url('ftp://frappe.cloud', valid_schemes=['https', 'ftp']))
self.assertFalse(validate_url('bolo://frappe.io', valid_schemes=("http", "https", "ftp", "ftps")))
self.assertRaises(
frappe.ValidationError,
validate_url,
'gopher://frappe.io',
valid_schemes='https',
throw=True
)
def test_valid_email(self):
# Edge cases
self.assertFalse(validate_email_address(''))
self.assertFalse(validate_email_address(None))
# Valid addresses
self.assertTrue(validate_email_address('someone@frappe.com'))
self.assertTrue(validate_email_address('someone@frappe.com, anyone@frappe.io'))
# Invalid address
self.assertFalse(validate_email_address('someone'))
self.assertFalse(validate_email_address('someone@----.com'))
# Invalid with throw
self.assertRaises(
frappe.InvalidEmailAddressError,
validate_email_address,
'someone.com',
throw=True
)
class TestImage(unittest.TestCase):
def test_strip_exif_data(self):
original_image = Image.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg")

View file

@ -443,8 +443,16 @@ def get_messages_from_report(name):
messages = _get_messages_from_page_or_report("Report", name,
frappe.db.get_value("DocType", report.ref_doctype, "module"))
if report.columns:
context = "Column of report '%s'" % report.name # context has to match context in `prepare_columns` in query_report.js
messages.extend([(None, report_column.label, context) for report_column in report.columns])
if report.filters:
messages.extend([(None, report_filter.label) for report_filter in report.filters])
if report.query:
messages.extend([(None, message) for message in re.findall('"([^:,^"]*):', report.query) if is_translatable(message)])
messages.append((None,report.report_name))
return messages

View file

@ -15,7 +15,6 @@ from email.utils import formataddr, parseaddr
from gzip import GzipFile
from typing import Generator, Iterable
from urllib.parse import quote, urlparse
from werkzeug.test import Client
import frappe
@ -156,6 +155,33 @@ def split_emails(txt):
return email_list
def validate_url(txt, throw=False, valid_schemes=None):
"""
Checks whether `txt` has a valid URL string
Parameters:
throw (`bool`): throws a validationError if URL is not valid
valid_schemes (`str` or `list`): if provided checks the given URL's scheme against this
Returns:
bool: if `txt` represents a valid URL
"""
url = urlparse(txt)
is_valid = bool(url.netloc)
# Handle scheme validation
if isinstance(valid_schemes, str):
is_valid = is_valid and (url.scheme == valid_schemes)
elif isinstance(valid_schemes, (list, tuple, set)):
is_valid = is_valid and (url.scheme in valid_schemes)
if not is_valid and throw:
frappe.throw(
frappe._("'{0}' is not a valid URL").format(frappe.bold(txt))
)
return is_valid
def random_string(length):
"""generate a random string"""
import string
@ -821,11 +847,3 @@ def groupby_metric(iterable: typing.Dict[str, list], key: str):
for item in items:
records.setdefault(item[key], {}).setdefault(category, []).append(item)
return records
def validate_url(url_string):
try:
result = urlparse(url_string)
return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"]
except Exception:
return False

View file

@ -6,7 +6,9 @@
"engine": "InnoDB",
"field_order": [
"email",
"status"
"status",
"anonymization_matrix",
"deletion_steps"
],
"fields": [
{
@ -27,10 +29,23 @@
"label": "Status",
"options": "Pending Verification\nPending Approval\nDeleted",
"read_only": 1
},
{
"fieldname": "anonymization_matrix",
"fieldtype": "Code",
"label": "Anonymization Matrix",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "deletion_steps",
"fieldtype": "Table",
"label": "Deletion Steps ",
"options": "Personal Data Deletion Step"
}
],
"links": [],
"modified": "2021-02-28 12:36:08.219719",
"modified": "2021-04-23 13:25:53.629308",
"modified_by": "Administrator",
"module": "Website",
"name": "Personal Data Deletion Request",

View file

@ -10,6 +10,8 @@ from frappe.model.document import Document
from frappe.utils import get_fullname
from frappe.utils.user import get_system_managers
from frappe.utils.verified_command import get_signed_params, verify_request
import json
from frappe.core.utils import find
class PersonalDataDeletionRequest(Document):
@ -118,6 +120,24 @@ class PersonalDataDeletionRequest(Document):
now=frappe.flags.in_test,
)
def add_deletion_steps(self):
if self.deletion_steps:
return
for step in self.full_match_privacy_docs + self.partial_privacy_docs:
row_data = {
"status": "Pending",
"document_type": step.get("doctype"),
"partial": step.get("partial") or False,
"fields": json.dumps(step.get("redact_fields", [])),
"filtered_by": step.get("filtered_by") or "",
}
self.append("deletion_steps", row_data)
self.anonymization_matrix = json.dumps(self.anonymization_value_map, indent=4)
self.save()
self.reload()
def redact_partial_match_data(self, doctype):
self.__redact_partial_match_data(doctype)
self.rename_documents(doctype)
@ -207,21 +227,57 @@ class PersonalDataDeletionRequest(Document):
ref["doctype"], doc["name"], self.anon, force=True, show_alert=False
)
def _anonymize_data(self, email=None, anon=None, set_data=True):
def _anonymize_data(self, email=None, anon=None, set_data=True, commit=False):
email = email or self.email
anon = anon or self.name
if set_data:
self.__set_anonymization_data(email, anon)
for doctype in self.full_match_privacy_docs:
self.redact_full_match_data(doctype, email)
self.add_deletion_steps()
for doctype in self.partial_privacy_docs:
self.full_match_doctypes = (
x
for x in self.full_match_privacy_docs
if filter(
lambda x: x.document_type == x and x.status == "Pending", self.deletion_steps
)
)
self.partial_match_doctypes = (
x
for x in self.partial_privacy_docs
if filter(
lambda x: x.document_type == x and x.status == "Pending", self.deletion_steps
)
)
for doctype in self.full_match_doctypes:
self.redact_full_match_data(doctype, email)
self.set_step_status(doctype["doctype"])
if commit:
frappe.db.commit()
for doctype in self.partial_match_doctypes:
self.redact_partial_match_data(doctype)
self.set_step_status(doctype["doctype"])
if commit:
frappe.db.commit()
frappe.rename_doc("User", email, anon, force=True, show_alert=False)
self.db_set("status", "Deleted")
if commit:
frappe.db.commit()
def set_step_status(self, step, status="Deleted"):
del_step = find(self.deletion_steps, lambda x: x.document_type == step and x.status != status)
if not del_step:
del_step = find(self.deletion_steps, lambda x: x.document_type == step)
del_step.status = status
self.save()
self.reload()
def __set_anonymization_data(self, email, anon):
self.anon = anon or self.name
@ -290,9 +346,8 @@ def confirm_deletion(email, name, host_name):
frappe.db.commit()
frappe.respond_as_web_page(
_("Confirmed"),
_(
"The process for deletion of {0} data associated with {1} has been initiated."
).format(host_name, email),
_("The process for deletion of {0} data associated with {1} has been initiated.")
.format(host_name, email),
indicator_color="green",
)

View file

@ -0,0 +1,66 @@
{
"actions": [],
"creation": "2021-04-23 13:25:26.162797",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"document_type",
"status",
"partial",
"fields",
"filtered_by"
],
"fields": [
{
"allow_in_quick_entry": 1,
"fieldname": "document_type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Document Type",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_preview": 1,
"label": "Status",
"options": "Pending\nDeleted",
"read_only": 1
},
{
"default": "0",
"fieldname": "partial",
"fieldtype": "Check",
"in_preview": 1,
"label": "Partial",
"read_only": 1
},
{
"fieldname": "fields",
"fieldtype": "Small Text",
"label": "Fields",
"read_only": 1
},
{
"fieldname": "filtered_by",
"fieldtype": "Data",
"label": "Filtered By",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-04-23 13:48:59.658681",
"modified_by": "Administrator",
"module": "Website",
"name": "Personal Data Deletion Step",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class PersonalDataDeletionStep(Document):
pass

View file

@ -39,7 +39,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Fieldtype",
"options": "Attach\nAttach Image\nCheck\nCurrency\nData\nDate\nDatetime\nDuration\nFloat\nHTML\nInt\nLink\nRating\nSelect\nSmall Text\nText\nText Editor\nTable\nSection Break\nColumn Break"
"options": "Attach\nAttach Image\nCheck\nCurrency\nData\nDate\nDatetime\nDuration\nFloat\nHTML\nInt\nLink\nPassword\nRating\nSelect\nSmall Text\nText\nText Editor\nTable\nSection Break\nColumn Break"
},
{
"fieldname": "label",
@ -146,7 +146,7 @@
],
"istable": 1,
"links": [],
"modified": "2020-11-10 23:20:44.354862",
"modified": "2021-04-30 12:02:25.422345",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Form Field",

View file

@ -16,7 +16,9 @@
{%- endif -%}
<div class="split-section-content col-12 {{ left_col if image_on_right else right_col }} {{ align_content }}">
<h2>{{ title }}</h2>
{%- if content -%}
<p>{{ content }}</p>
{%- endif -%}
{%- if link_label and link_url -%}
<a href="{{ link_url }}">{{ link_label }}</a>