Merge branch 'develop' into develop
This commit is contained in:
commit
eb70f0aa9a
119 changed files with 3041 additions and 1201 deletions
|
|
@ -6,6 +6,7 @@ pull_request_rules:
|
|||
- author!=surajshetty3416
|
||||
- author!=gavindsouza
|
||||
- author!=deepeshgarg007
|
||||
- author!=ankush
|
||||
- or:
|
||||
- base=version-13
|
||||
- base=version-12
|
||||
|
|
@ -13,7 +14,7 @@ pull_request_rules:
|
|||
close:
|
||||
comment:
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
|
||||
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
|
||||
|
||||
- name: Automatic merge on CI success and review
|
||||
|
|
@ -53,7 +54,7 @@ pull_request_rules:
|
|||
{{ title }} (#{{ number }})
|
||||
|
||||
{{ body }}
|
||||
|
||||
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
|
|
@ -92,4 +93,4 @@ pull_request_rules:
|
|||
branches:
|
||||
- version-12-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- "{{ author }}"
|
||||
|
|
|
|||
77
cypress/integration/control_color.js
Normal file
77
cypress/integration/control_color.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
context('Control Color', () => {
|
||||
before(() => {
|
||||
cy.login();
|
||||
cy.visit('/app/website');
|
||||
});
|
||||
|
||||
function get_dialog_with_color() {
|
||||
return cy.dialog({
|
||||
title: 'Color',
|
||||
fields: [{
|
||||
label: 'Color',
|
||||
fieldname: 'color',
|
||||
fieldtype: 'Color'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
it('Verifying if the color control is selecting correct', () => {
|
||||
get_dialog_with_color().as('dialog');
|
||||
cy.findByPlaceholderText('Choose a color').click();
|
||||
|
||||
///Selecting a color from the color palette
|
||||
cy.get('[style="background-color: rgb(79, 157, 217);"]').click();
|
||||
|
||||
//Checking if the css attribute is correct
|
||||
cy.get('.color-map').should('have.css', 'color', 'rgb(79, 157, 217)');
|
||||
cy.get('.hue-map').should('have.css', 'color', 'rgb(0, 145, 255)');
|
||||
|
||||
//Checking if the correct color is being selected
|
||||
cy.get('@dialog').then(dialog => {
|
||||
let value = dialog.get_value('color');
|
||||
expect(value).to.equal('#4F9DD9');
|
||||
});
|
||||
|
||||
//Selecting a color
|
||||
cy.get('[style="background-color: rgb(203, 41, 41);"]').click();
|
||||
|
||||
//Checking if the correct css is being selected
|
||||
cy.get('.color-map').should('have.css', 'color', 'rgb(203, 41, 41)');
|
||||
cy.get('.hue-map').should('have.css', 'color', 'rgb(255, 0, 0)');
|
||||
|
||||
//Checking if the correct color is being selected
|
||||
cy.get('@dialog').then(dialog => {
|
||||
let value = dialog.get_value('color');
|
||||
expect(value).to.equal('#CB2929');
|
||||
});
|
||||
|
||||
//Selecting color from the palette
|
||||
cy.get('.color-map > .color-selector').click(65, 87, {force: true});
|
||||
cy.get('.color-map').should('have.css', 'color', 'rgb(56, 0, 0)');
|
||||
|
||||
//Checking if the expected color is selected and getting displayed
|
||||
cy.get('@dialog').then(dialog => {
|
||||
let value = dialog.get_value('color');
|
||||
expect(value).to.equal('#380000');
|
||||
});
|
||||
|
||||
//Selecting the color from the hue map
|
||||
cy.get('.hue-map > .hue-selector').click(35, -1, {force: true});
|
||||
cy.get('.color-map').should('have.css', 'color', 'rgb(56, 45, 0)');
|
||||
cy.get('.hue-map').should('have.css', 'color', 'rgb(255, 204, 0)');
|
||||
cy.get('.color-map > .color-selector').click(55, 12, {force: true});
|
||||
cy.get('.color-map').should('have.css', 'color', 'rgb(46, 37, 0)');
|
||||
|
||||
//Checking if the correct color is being displayed
|
||||
cy.get('@dialog').then(dialog => {
|
||||
let value = dialog.get_value('color');
|
||||
expect(value).to.equal('#2e2500');
|
||||
});
|
||||
|
||||
//Clearing the field and checking if the field contains the placeholder "Choose a color"
|
||||
cy.get('.input-with-feedback').click({force: true});
|
||||
cy.get_field('color', 'Color').type('{selectall}').clear();
|
||||
cy.get_field('color', 'Color').invoke('attr', 'placeholder').should('contain', 'Choose a color');
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -59,7 +59,7 @@ context('Data Control', () => {
|
|||
//Checking for the error message
|
||||
cy.get('.modal-title').should('have.text', 'Message');
|
||||
cy.get('.msgprint').should('have.text', '@@### is not a valid Name');
|
||||
cy.get('.modal').type('{esc}');
|
||||
cy.hide_dialog();
|
||||
|
||||
cy.get_field('name1', 'Data').clear({force: true});
|
||||
cy.fill_field('name1', 'Komal{}/!', 'Data');
|
||||
|
|
@ -67,10 +67,10 @@ context('Data Control', () => {
|
|||
cy.findByRole('button', {name: 'Save'}).click();
|
||||
cy.get('.modal-title').should('have.text', 'Message');
|
||||
cy.get('.msgprint').should('have.text', 'Komal{}/! is not a valid Name');
|
||||
cy.hide_dialog();
|
||||
});
|
||||
|
||||
it('Verifying data control by inputting different patterns for "Email" field', () => {
|
||||
cy.get('.modal-actions > .btn-modal-close').trigger("click");
|
||||
cy.get_field('name1', 'Data').clear({force: true});
|
||||
cy.fill_field('name1', 'Komal', 'Data');
|
||||
cy.get_field('email', 'Data').clear({force: true});
|
||||
|
|
@ -79,17 +79,17 @@ context('Data Control', () => {
|
|||
cy.findByRole('button', {name: 'Save'}).click();
|
||||
cy.get('.modal-title').should('have.text', 'Message');
|
||||
cy.get('.msgprint').should('have.text', 'komal is not a valid Email Address');
|
||||
cy.get('.modal-actions > .btn-modal-close').trigger("click");
|
||||
cy.hide_dialog();
|
||||
cy.get_field('email', 'Data').clear({force: true});
|
||||
cy.fill_field('email', 'komal@test', 'Data');
|
||||
cy.get('.frappe-control[data-fieldname="email"]').should('have.class', 'has-error');
|
||||
cy.findByRole('button', {name: 'Save'}).click();
|
||||
cy.get('.modal-title').should('have.text', 'Message');
|
||||
cy.get('.msgprint').should('have.text', 'komal@test is not a valid Email Address');
|
||||
cy.hide_dialog();
|
||||
});
|
||||
|
||||
it('Verifying data control by inputting different patterns for "Phone" field', () => {
|
||||
cy.get('.modal-actions > .btn-modal-close').trigger("click");
|
||||
cy.get_field('email', 'Data').clear({force: true});
|
||||
cy.fill_field('email', 'komal@test.com', 'Data');
|
||||
cy.get_field('phone', 'Data').clear({force: true});
|
||||
|
|
@ -98,7 +98,7 @@ context('Data Control', () => {
|
|||
cy.findByRole('button', {name: 'Save'}).click({force: true});
|
||||
cy.get('.modal-title').should('have.text', 'Message');
|
||||
cy.get('.msgprint').should('have.text', 'komal is not a valid Phone Number');
|
||||
cy.get('.modal-actions > .btn-modal-close').trigger("click");
|
||||
cy.hide_dialog();
|
||||
});
|
||||
|
||||
it('Inputting correct data and saving the doc', () => {
|
||||
|
|
@ -124,6 +124,5 @@ context('Data Control', () => {
|
|||
cy.get('.actions-btn-group > .btn').contains('Actions').click();
|
||||
cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click();
|
||||
cy.click_modal_primary_button('Yes');
|
||||
cy.get('.btn-modal-close').click();
|
||||
});
|
||||
});
|
||||
|
|
@ -20,7 +20,21 @@ context('Control Link', () => {
|
|||
'label': 'Select ToDo',
|
||||
'fieldname': 'link',
|
||||
'fieldtype': 'Link',
|
||||
'options': 'ToDo'
|
||||
'options': 'ToDo',
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function get_dialog_with_user_link() {
|
||||
return cy.dialog({
|
||||
title: 'Link',
|
||||
fields: [
|
||||
{
|
||||
'label': 'Select User',
|
||||
'fieldname': 'link',
|
||||
'fieldtype': 'Link',
|
||||
'options': 'User',
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -29,6 +43,24 @@ context('Control Link', () => {
|
|||
it('should set the valid value', () => {
|
||||
get_dialog_with_link().as('dialog');
|
||||
|
||||
cy.insert_doc("Property Setter", {
|
||||
"doctype": "Property Setter",
|
||||
"doc_type": "User",
|
||||
"property": "translate_link_fields",
|
||||
"property_type": "Check",
|
||||
"doctype_or_field": "DocType",
|
||||
"value": "0"
|
||||
}, true);
|
||||
|
||||
cy.insert_doc("Property Setter", {
|
||||
"doctype": "Property Setter",
|
||||
"doc_type": "ToDo",
|
||||
"property": "show_title_field_in_link",
|
||||
"property_type": "Check",
|
||||
"doctype_or_field": "DocType",
|
||||
"value": "0"
|
||||
}, true);
|
||||
|
||||
cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link');
|
||||
|
||||
cy.get('.frappe-control[data-fieldname=link] input').focus().as('input');
|
||||
|
|
@ -88,7 +120,8 @@ context('Control Link', () => {
|
|||
cy.get('@input').type(todos[0]).blur();
|
||||
cy.wait('@validate_link');
|
||||
cy.get('@input').focus();
|
||||
cy.findByTitle('Open Link')
|
||||
cy.wait(500); // wait for arrow to show
|
||||
cy.get('.frappe-control[data-fieldname=link] .btn-open')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.location('pathname').should('eq', `/app/todo/${todos[0]}`);
|
||||
|
|
@ -96,7 +129,15 @@ context('Control Link', () => {
|
|||
});
|
||||
|
||||
it('show title field in link', () => {
|
||||
get_dialog_with_link().as('dialog');
|
||||
|
||||
cy.insert_doc("Property Setter", {
|
||||
"doctype": "Property Setter",
|
||||
"doc_type": "User",
|
||||
"property": "translate_link_fields",
|
||||
"property_type": "Check",
|
||||
"doctype_or_field": "DocType",
|
||||
"value": "0"
|
||||
}, true);
|
||||
|
||||
cy.insert_doc("Property Setter", {
|
||||
"doctype": "Property Setter",
|
||||
|
|
@ -107,6 +148,10 @@ context('Control Link', () => {
|
|||
"value": "1"
|
||||
}, true);
|
||||
|
||||
cy.clear_cache();
|
||||
cy.wait(500);
|
||||
|
||||
get_dialog_with_link().as('dialog');
|
||||
cy.window().its('frappe').then(frappe => {
|
||||
if (!frappe.boot) {
|
||||
frappe.boot = {
|
||||
|
|
@ -134,8 +179,6 @@ context('Control Link', () => {
|
|||
|
||||
expect(value).to.eq(todos[0]);
|
||||
expect(label).to.eq('this is a test todo for link');
|
||||
|
||||
cy.remove_doc("Property Setter", "ToDo-main-show_title_field_in_link");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -143,6 +186,7 @@ context('Control Link', () => {
|
|||
it('should update dependant fields (via fetch_from)', () => {
|
||||
cy.get('@todos').then(todos => {
|
||||
cy.visit(`/app/todo/${todos[0]}`);
|
||||
cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link');
|
||||
cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link');
|
||||
|
||||
cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input');
|
||||
|
|
@ -167,7 +211,9 @@ context('Control Link', () => {
|
|||
.should("eq", null);
|
||||
|
||||
// set valid value again
|
||||
cy.get('@input').clear().type('Administrator', {delay: 100}).blur();
|
||||
cy.get('@input').clear().focus();
|
||||
cy.wait('@search_link');
|
||||
cy.get('@input').type('Administrator', {delay: 100}).blur();
|
||||
cy.wait('@validate_link');
|
||||
|
||||
cy.window()
|
||||
|
|
@ -214,4 +260,130 @@ context('Control Link', () => {
|
|||
"contain", ""
|
||||
);
|
||||
});
|
||||
|
||||
it('show translated text for link with show_title_field_in_link enabled', () => {
|
||||
cy.insert_doc("Property Setter", {
|
||||
"doctype": "Property Setter",
|
||||
"doc_type": "ToDo",
|
||||
"property": "translate_link_fields",
|
||||
"property_type": "Check",
|
||||
"doctype_or_field": "DocType",
|
||||
"value": "1"
|
||||
}, true);
|
||||
|
||||
cy.insert_doc("Property Setter", {
|
||||
"doctype": "Property Setter",
|
||||
"doc_type": "ToDo",
|
||||
"property": "show_title_field_in_link",
|
||||
"property_type": "Check",
|
||||
"doctype_or_field": "DocType",
|
||||
"value": "1"
|
||||
}, true);
|
||||
|
||||
cy.window().its('frappe').then(frappe => {
|
||||
cy.insert_doc("Translation", {
|
||||
doctype: "Translation",
|
||||
language: frappe.boot.lang,
|
||||
source_text: "this is a test todo for link",
|
||||
translated_text: "this is a translated test todo for link",
|
||||
});
|
||||
});
|
||||
|
||||
cy.clear_cache();
|
||||
cy.wait(500);
|
||||
|
||||
cy.window().its('frappe').then(frappe => {
|
||||
if (!frappe.boot) {
|
||||
frappe.boot = {
|
||||
link_title_doctypes: ['ToDo'],
|
||||
translatable_doctypes: ['ToDo']
|
||||
};
|
||||
} else {
|
||||
frappe.boot.link_title_doctypes = ['ToDo'];
|
||||
frappe.boot.translatable_doctypes = ['ToDo'];
|
||||
}
|
||||
});
|
||||
|
||||
get_dialog_with_link().as('dialog');
|
||||
cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link');
|
||||
|
||||
cy.get('.frappe-control[data-fieldname=link] input').focus().as('input');
|
||||
cy.wait('@search_link');
|
||||
cy.get('@input').type('todo for link', { delay: 100 });
|
||||
cy.wait('@search_link');
|
||||
cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible');
|
||||
cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 });
|
||||
cy.get('.frappe-control[data-fieldname=link] input').blur();
|
||||
cy.get('@dialog').then(dialog => {
|
||||
cy.get('@todos').then(todos => {
|
||||
let field = dialog.get_field('link');
|
||||
let value = field.get_value();
|
||||
let label = field.get_label_value();
|
||||
|
||||
expect(value).to.eq(todos[0]);
|
||||
expect(label).to.eq('this is a translated test todo for link');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('show translated text for link with show_title_field_in_link disabled', () => {
|
||||
cy.insert_doc("Property Setter", {
|
||||
"doctype": "Property Setter",
|
||||
"doc_type": "User",
|
||||
"property": "translate_link_fields",
|
||||
"property_type": "Check",
|
||||
"doctype_or_field": "DocType",
|
||||
"value": "1"
|
||||
}, true);
|
||||
|
||||
cy.insert_doc("Property Setter", {
|
||||
"doctype": "Property Setter",
|
||||
"doc_type": "ToDo",
|
||||
"property": "show_title_field_in_link",
|
||||
"property_type": "Check",
|
||||
"doctype_or_field": "DocType",
|
||||
"value": "0"
|
||||
}, true);
|
||||
|
||||
cy.window().its('frappe').then(frappe => {
|
||||
cy.insert_doc("Translation", {
|
||||
doctype: "Translation",
|
||||
language: frappe.boot.lang,
|
||||
source_text: "test@erpnext.com",
|
||||
translated_text: "translatedtest@erpnext.com",
|
||||
});
|
||||
});
|
||||
|
||||
cy.clear_cache();
|
||||
cy.wait(500);
|
||||
|
||||
cy.window().its('frappe').then(frappe => {
|
||||
if (!frappe.boot) {
|
||||
frappe.boot = {
|
||||
translatable_doctypes: ['User']
|
||||
};
|
||||
} else {
|
||||
frappe.boot.translatable_doctypes = ['User'];
|
||||
}
|
||||
});
|
||||
|
||||
get_dialog_with_user_link().as('dialog');
|
||||
cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link');
|
||||
|
||||
cy.get('.frappe-control[data-fieldname=link] input').focus().as('input');
|
||||
cy.wait('@search_link');
|
||||
cy.get('@input').type('test@erpnext.com', { delay: 100 });
|
||||
cy.wait('@search_link');
|
||||
cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible');
|
||||
cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 });
|
||||
cy.get('.frappe-control[data-fieldname=link] input').blur();
|
||||
cy.get('@dialog').then(dialog => {
|
||||
let field = dialog.get_field('link');
|
||||
let value = field.get_value();
|
||||
let label = field.get_label_value();
|
||||
|
||||
expect(value).to.eq('test@erpnext.com');
|
||||
expect(label).to.eq('translatedtest@erpnext.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ context('Folder Navigation', () => {
|
|||
//Adding folder (Test Folder)
|
||||
cy.get('.menu-btn-group > .btn').click();
|
||||
cy.get('.menu-btn-group [data-label="New Folder"]').click();
|
||||
cy.get('form > [data-fieldname="value"]').type('Test Folder');
|
||||
cy.findByRole('button', {name: 'Create'}).click();
|
||||
cy.fill_field('value', 'Test Folder');
|
||||
cy.click_modal_primary_button('Create');
|
||||
});
|
||||
|
||||
it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => {
|
||||
|
|
@ -32,8 +32,8 @@ context('Folder Navigation', () => {
|
|||
//Adding folder inside the attachments folder
|
||||
cy.get('.menu-btn-group > .btn').click();
|
||||
cy.get('.menu-btn-group [data-label="New Folder"]').click();
|
||||
cy.get('form > [data-fieldname="value"]').type('Test Folder');
|
||||
cy.findByRole('button', {name: 'Create'}).click();
|
||||
cy.fill_field('value', 'Test Folder');
|
||||
cy.click_modal_primary_button('Create');
|
||||
|
||||
//Navigating inside the added folder in the Attachments folder
|
||||
cy.get('[title="Test Folder"] > span').click();
|
||||
|
|
@ -46,26 +46,31 @@ context('Folder Navigation', () => {
|
|||
cy.findByRole('button', {name: 'Add File'}).eq(0).click({force: true});
|
||||
cy.get('.file-uploader').findByText('Link').click();
|
||||
cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg');
|
||||
cy.findByRole('button', {name: 'Upload'}).click();
|
||||
cy.click_modal_primary_button('Upload');
|
||||
|
||||
//To check if the added file is present in the Test Folder
|
||||
cy.get('span.level-item > span').should('contain', 'Test Folder');
|
||||
cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg');
|
||||
cy.get('.list-row-checkbox').eq(0).click();
|
||||
|
||||
cy.intercept({
|
||||
method: 'POST',
|
||||
url: 'api/method/frappe.desk.reportview.delete_items'
|
||||
}).as('file_deleted');
|
||||
|
||||
//Deleting the added file from the Test folder
|
||||
cy.findByRole('button', {name: 'Actions'}).click();
|
||||
cy.get('.actions-btn-group [data-label="Delete"]').click();
|
||||
cy.wait(700);
|
||||
cy.findByRole('button', {name: 'Yes'}).click();
|
||||
cy.wait(700);
|
||||
cy.click_modal_primary_button('Yes');
|
||||
cy.wait('@file_deleted');
|
||||
|
||||
//Deleting the Test Folder
|
||||
cy.visit('/app/file/view/home/Attachments');
|
||||
cy.get('.list-row-checkbox').eq(0).click();
|
||||
cy.findByRole('button', {name: 'Actions'}).click();
|
||||
cy.get('.actions-btn-group [data-label="Delete"]').click();
|
||||
cy.findByRole('button', {name: 'Yes'}).click();
|
||||
cy.click_modal_primary_button('Yes');
|
||||
cy.wait('@file_deleted');
|
||||
});
|
||||
|
||||
it('Deleting Test Folder from the home', () => {
|
||||
|
|
@ -74,6 +79,6 @@ context('Folder Navigation', () => {
|
|||
cy.get('.level-left > .list-subject > .file-select >.list-row-checkbox').eq(0).click({force: true, delay: 500});
|
||||
cy.findByRole('button', {name: 'Actions'}).click();
|
||||
cy.get('.actions-btn-group [data-label="Delete"]').click();
|
||||
cy.findByRole('button', {name: 'Yes'}).click();
|
||||
cy.click_modal_primary_button('Yes');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
context.skip('Form Tour', () => {
|
||||
before(() => {
|
||||
cy.login();
|
||||
cy.visit('/app/form-tour');
|
||||
cy.visit('/app');
|
||||
return cy.window().its('frappe').then(frappe => {
|
||||
return frappe.call("frappe.tests.ui_test_helpers.create_form_tour");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ context('Kanban Board', () => {
|
|||
|
||||
cy.click_listview_primary_button('Add ToDo');
|
||||
|
||||
cy.fill_field('description', 'Test Kanban ToDo', 'Text Editor');
|
||||
cy.fill_field('description', 'Test Kanban ToDo', 'Text Editor').wait(300);
|
||||
cy.get('.modal-footer .btn-primary').last().click();
|
||||
|
||||
cy.wait('@save-todo');
|
||||
|
|
|
|||
|
|
@ -48,4 +48,4 @@ context('Table MultiSelect', () => {
|
|||
cy.get('@existing_value').find('.btn-link-to-form').click();
|
||||
cy.location('pathname').should('contain', '/user/test@erpnext.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -78,12 +78,5 @@ context('Timeline', () => {
|
|||
cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click();
|
||||
cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click();
|
||||
cy.click_modal_primary_button('Yes');
|
||||
|
||||
//Deleting the custom doctype
|
||||
cy.visit('/app/doctype');
|
||||
cy.select_listview_row_checkbox(0);
|
||||
cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click();
|
||||
cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click();
|
||||
cy.click_modal_primary_button('Yes');
|
||||
});
|
||||
});
|
||||
150
cypress/integration/workspace_blocks.js
Normal file
150
cypress/integration/workspace_blocks.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
context('Workspace Blocks', () => {
|
||||
before(() => {
|
||||
cy.login();
|
||||
cy.visit('/app');
|
||||
});
|
||||
|
||||
it('Create Test Page', () => {
|
||||
cy.intercept({
|
||||
method: 'POST',
|
||||
url: 'api/method/frappe.desk.doctype.workspace.workspace.new_page'
|
||||
}).as('new_page');
|
||||
|
||||
cy.get('.codex-editor__redactor .ce-block');
|
||||
cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
|
||||
cy.fill_field('title', 'Test Block Page', 'Data');
|
||||
cy.fill_field('icon', 'edit', 'Icon');
|
||||
cy.get_open_dialog().find('.modal-header').click();
|
||||
cy.get_open_dialog().find('.btn-primary').click();
|
||||
|
||||
// check if sidebar item is added in private section
|
||||
cy.get('.sidebar-item-container[item-name="Test Block Page"]').should('have.attr', 'item-public', '0');
|
||||
|
||||
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
|
||||
cy.wait(300);
|
||||
cy.get('.sidebar-item-container[item-name="Test Block Page"]').should('have.attr', 'item-public', '0');
|
||||
|
||||
cy.wait('@new_page');
|
||||
});
|
||||
|
||||
it('Quick List Block', () => {
|
||||
cy.create_records([
|
||||
{
|
||||
doctype: 'ToDo',
|
||||
description: 'Quick List ToDo 1',
|
||||
status: 'Open'
|
||||
},
|
||||
{
|
||||
doctype: 'ToDo',
|
||||
description: 'Quick List ToDo 2',
|
||||
status: 'Open'
|
||||
},
|
||||
{
|
||||
doctype: 'ToDo',
|
||||
description: 'Quick List ToDo 3',
|
||||
status: 'Open'
|
||||
},
|
||||
{
|
||||
doctype: 'ToDo',
|
||||
description: 'Quick List ToDo 4',
|
||||
status: 'Open'
|
||||
},
|
||||
{
|
||||
doctype: 'ToDo',
|
||||
description: 'Quick List ToDo 5',
|
||||
status: 'Closed'
|
||||
},
|
||||
{
|
||||
doctype: 'ToDo',
|
||||
description: 'Quick List ToDo 6',
|
||||
status: 'Closed'
|
||||
},
|
||||
{
|
||||
doctype: 'ToDo',
|
||||
description: 'Quick List ToDo 7',
|
||||
status: 'Closed'
|
||||
},
|
||||
{
|
||||
doctype: 'ToDo',
|
||||
description: 'Quick List ToDo 8',
|
||||
status: 'Closed'
|
||||
}
|
||||
]);
|
||||
|
||||
cy.get('.codex-editor__redactor .ce-block');
|
||||
cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();
|
||||
|
||||
// test quick list creation
|
||||
cy.get('.ce-block').first().click({force: true}).type('{enter}');
|
||||
cy.get('.block-list-container .block-list-item').contains('Quick List').click();
|
||||
|
||||
cy.get_open_dialog().find('.modal-header').click();
|
||||
|
||||
cy.fill_field('document_type', 'ToDo', 'Link').blur();
|
||||
cy.fill_field('label', 'ToDo', 'Data').blur();
|
||||
|
||||
cy.get_open_dialog().find('.filter-edit-area').should('contain', 'No filters selected');
|
||||
cy.get_open_dialog().find('.filter-area .add-filter').click();
|
||||
|
||||
cy.get_open_dialog().find('.fieldname-select-area input').type('Status{enter}').blur();
|
||||
cy.get_open_dialog().find('select.input-with-feedback').select('Open');
|
||||
|
||||
cy.get_open_dialog().find('.modal-header').click();
|
||||
cy.get_open_dialog().find('.btn-primary').click();
|
||||
|
||||
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
|
||||
|
||||
|
||||
cy.get('.codex-editor__redactor .ce-block');
|
||||
|
||||
cy.get('.ce-block .quick-list-widget-box').first().as('todo-quick-list');
|
||||
|
||||
cy.get('@todo-quick-list').find('.quick-list-item .status').should('contain', 'Open');
|
||||
|
||||
// test filter-list
|
||||
cy.get('@todo-quick-list').find('.widget-control .filter-list').click();
|
||||
|
||||
cy.get_open_dialog().find('select.input-with-feedback').select('Closed');
|
||||
cy.get_open_dialog().find('.modal-header').click();
|
||||
cy.get_open_dialog().find('.btn-primary').click();
|
||||
|
||||
cy.get('@todo-quick-list').find('.quick-list-item .status').should('contain', 'Closed');
|
||||
|
||||
|
||||
// test refresh-list
|
||||
cy.intercept({
|
||||
method: 'POST',
|
||||
url: 'api/method/frappe.desk.reportview.get'
|
||||
}).as('refresh-list');
|
||||
|
||||
cy.get('@todo-quick-list').find('.widget-control .refresh-list').click();
|
||||
cy.wait('@refresh-list');
|
||||
|
||||
|
||||
// test add-new
|
||||
cy.get('@todo-quick-list').find('.widget-control .add-new').click();
|
||||
cy.url().should('include', `/todo/new-todo-1`);
|
||||
cy.go('back');
|
||||
|
||||
|
||||
// test quick-list-item
|
||||
cy.get('@todo-quick-list').find('.quick-list-item .title')
|
||||
.first()
|
||||
.invoke('attr', 'title')
|
||||
.then(title => {
|
||||
cy.get('@todo-quick-list').find('.quick-list-item').contains(title).click();
|
||||
cy.get_field('description', 'Text Editor').should('contain', title);
|
||||
});
|
||||
cy.go('back');
|
||||
|
||||
|
||||
// test see-all
|
||||
cy.get('@todo-quick-list').find('.widget-footer .see-all').click();
|
||||
|
||||
cy.get('.standard-filter-section select[data-fieldname="status"]')
|
||||
.invoke('val')
|
||||
.should('eq', 'Open');
|
||||
cy.go('back');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -341,7 +341,8 @@ Cypress.Commands.add('clear_filters', () => {
|
|||
});
|
||||
|
||||
Cypress.Commands.add('click_modal_primary_button', (btn_name) => {
|
||||
cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).trigger('click', {force: true});
|
||||
cy.wait(400);
|
||||
cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).click({force: true});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('click_sidebar_button', (btn_name) => {
|
||||
|
|
|
|||
|
|
@ -10,28 +10,19 @@ be used to build database driven apps.
|
|||
|
||||
Read the documentation: https://frappeframework.com/docs
|
||||
"""
|
||||
import os
|
||||
import warnings
|
||||
|
||||
STANDARD_USERS = ("Guest", "Administrator")
|
||||
|
||||
_dev_server = os.environ.get("DEV_SERVER", False)
|
||||
|
||||
if _dev_server:
|
||||
warnings.simplefilter("always", DeprecationWarning)
|
||||
warnings.simplefilter("always", PendingDeprecationWarning)
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Dict, List, Union
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Union
|
||||
|
||||
import click
|
||||
from werkzeug.local import Local, release_local
|
||||
|
||||
from frappe.query_builder import get_query_builder, patch_query_aggregation, patch_query_execute
|
||||
from frappe.utils.data import cstr
|
||||
from frappe.utils.data import cstr, sbool
|
||||
|
||||
# Local application imports
|
||||
from .exceptions import *
|
||||
|
|
@ -45,11 +36,17 @@ from .utils.jinja import (
|
|||
from .utils.lazy_loader import lazy_import
|
||||
|
||||
__version__ = "14.0.0-dev"
|
||||
|
||||
__title__ = "Frappe Framework"
|
||||
|
||||
local = Local()
|
||||
controllers = {}
|
||||
local = Local()
|
||||
STANDARD_USERS = ("Guest", "Administrator")
|
||||
|
||||
_dev_server = int(sbool(os.environ.get("DEV_SERVER", False)))
|
||||
|
||||
if _dev_server:
|
||||
warnings.simplefilter("always", DeprecationWarning)
|
||||
warnings.simplefilter("always", PendingDeprecationWarning)
|
||||
|
||||
|
||||
class _dict(dict):
|
||||
|
|
@ -435,7 +432,7 @@ def msgprint(
|
|||
if as_table and type(msg) in (list, tuple):
|
||||
out.as_table = 1
|
||||
|
||||
if as_list and type(msg) in (list, tuple) and len(msg) > 1:
|
||||
if as_list and type(msg) in (list, tuple):
|
||||
out.as_list = 1
|
||||
|
||||
if flags.print_messages and out.message:
|
||||
|
|
@ -973,7 +970,7 @@ def get_precision(doctype, fieldname, currency=None, doc=None):
|
|||
return get_field_precision(get_meta(doctype).get_field(fieldname), doc, currency)
|
||||
|
||||
|
||||
def generate_hash(txt=None, length=None):
|
||||
def generate_hash(txt: Optional[str] = None, length: Optional[int] = None) -> str:
|
||||
"""Generates random hash for given text + current timestamp + random string."""
|
||||
import hashlib
|
||||
import time
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ def get_bootinfo():
|
|||
bootinfo.desk_settings = get_desk_settings()
|
||||
bootinfo.app_logo_url = get_app_logo()
|
||||
bootinfo.link_title_doctypes = get_link_title_doctypes()
|
||||
bootinfo.translatable_doctypes = get_translatable_doctypes()
|
||||
|
||||
return bootinfo
|
||||
|
||||
|
|
@ -408,3 +409,11 @@ def set_time_zone(bootinfo):
|
|||
"user": bootinfo.get("user_info", {}).get(frappe.session.user, {}).get("time_zone", None)
|
||||
or get_time_zone(),
|
||||
}
|
||||
|
||||
|
||||
def get_translatable_doctypes():
|
||||
dts = frappe.get_all("DocType", {"translate_link_fields": 1}, pluck="name")
|
||||
custom_dts = frappe.get_all(
|
||||
"Property Setter", {"property": "translate_link_fields", "value": "1"}, pluck="doc_type"
|
||||
)
|
||||
return dts + custom_dts
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# License: MIT. See LICENSE
|
||||
import json
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import frappe
|
||||
import frappe.model
|
||||
|
|
@ -11,6 +12,9 @@ from frappe.desk.reportview import validate_args
|
|||
from frappe.model.db_query import check_parent_permission
|
||||
from frappe.utils import get_safe_filters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.model.document import Document
|
||||
|
||||
"""
|
||||
Handle RESTful requests that are mapped to the `/api/resource` route.
|
||||
|
||||
|
|
@ -189,18 +193,7 @@ def insert(doc=None):
|
|||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
|
||||
doc = frappe._dict(doc)
|
||||
if frappe.is_table(doc.doctype):
|
||||
if not (doc.parenttype and doc.parent and doc.parentfield):
|
||||
frappe.throw(_("parenttype, parent and parentfield are required to insert a child record"))
|
||||
# inserting a child record
|
||||
parent = frappe.get_doc(doc.parenttype, doc.parent)
|
||||
parent.append(doc.parentfield, doc)
|
||||
parent.save()
|
||||
return parent.as_dict()
|
||||
else:
|
||||
doc = frappe.get_doc(doc).insert()
|
||||
return doc.as_dict()
|
||||
return insert_doc(doc).as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST", "PUT"])
|
||||
|
|
@ -211,21 +204,12 @@ def insert_many(docs=None):
|
|||
if isinstance(docs, str):
|
||||
docs = json.loads(docs)
|
||||
|
||||
out = []
|
||||
|
||||
if len(docs) > 200:
|
||||
frappe.throw(_("Only 200 inserts allowed in one request"))
|
||||
|
||||
out = set()
|
||||
for doc in docs:
|
||||
if doc.get("parenttype"):
|
||||
# inserting a child record
|
||||
parent = frappe.get_doc(doc.parenttype, doc.parent)
|
||||
parent.append(doc.parentfield, doc)
|
||||
parent.save()
|
||||
out.append(parent.name)
|
||||
else:
|
||||
doc = frappe.get_doc(doc).insert()
|
||||
out.append(doc.name)
|
||||
out.add(insert_doc(doc).name)
|
||||
|
||||
return out
|
||||
|
||||
|
|
@ -496,3 +480,23 @@ def validate_link(doctype: str, docname: str, fields=None):
|
|||
)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def insert_doc(doc) -> "Document":
|
||||
"""Inserts document and returns parent document object with appended child document
|
||||
if `doc` is child document else returns the inserted document object
|
||||
|
||||
:param doc: doc to insert (dict)"""
|
||||
|
||||
doc = frappe._dict(doc)
|
||||
if frappe.is_table(doc.doctype):
|
||||
if not (doc.parenttype and doc.parent and doc.parentfield):
|
||||
frappe.throw(_("Parenttype, Parent and Parentfield are required to insert a child record"))
|
||||
|
||||
# inserting a child record
|
||||
parent = frappe.get_doc(doc.parenttype, doc.parent)
|
||||
parent.append(doc.parentfield, doc)
|
||||
parent.save()
|
||||
return parent
|
||||
|
||||
return frappe.get_doc(doc).insert()
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ def new_site(
|
|||
db_root_password=None,
|
||||
admin_password=None,
|
||||
verbose=False,
|
||||
install_apps=None,
|
||||
source_sql=None,
|
||||
force=None,
|
||||
no_mariadb_socket=False,
|
||||
|
|
@ -398,8 +397,9 @@ def _reinstall(
|
|||
|
||||
@click.command("install-app")
|
||||
@click.argument("apps", nargs=-1)
|
||||
@click.option("--force", is_flag=True, default=False)
|
||||
@pass_context
|
||||
def install_app(context, apps):
|
||||
def install_app(context, apps, force=False):
|
||||
"Install a new app to site, supports multiple apps"
|
||||
from frappe.installer import install_app as _install_app
|
||||
|
||||
|
|
@ -414,7 +414,7 @@ def install_app(context, apps):
|
|||
|
||||
for app in apps:
|
||||
try:
|
||||
_install_app(app, verbose=context.verbose)
|
||||
_install_app(app, verbose=context.verbose, force=force)
|
||||
except frappe.IncompatibleApp as err:
|
||||
err_msg = ":\n{}".format(err) if str(err) else ""
|
||||
print("App {} is Incompatible with Site {}{}".format(app, site, err_msg))
|
||||
|
|
@ -825,7 +825,7 @@ def _drop_site(
|
|||
try:
|
||||
if not no_backup:
|
||||
click.secho(f"Taking backup of {site}", fg="green")
|
||||
odb = scheduled_backup(ignore_files=False, force=True, verbose=True)
|
||||
odb = scheduled_backup(ignore_files=False, ignore_conf=True, force=True, verbose=True)
|
||||
odb.print_summary()
|
||||
except Exception as err:
|
||||
if force:
|
||||
|
|
@ -923,7 +923,6 @@ def set_user_password(site, user, password, logout_all_sessions=False):
|
|||
|
||||
update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions)
|
||||
frappe.db.commit()
|
||||
password = None
|
||||
finally:
|
||||
frappe.destroy()
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ def load_address_and_contact(doc, key=None):
|
|||
["Dynamic Link", "link_name", "=", doc.name],
|
||||
["Dynamic Link", "parenttype", "=", "Address"],
|
||||
]
|
||||
address_list = frappe.get_list("Address", filters=filters, fields=["*"])
|
||||
address_list = frappe.get_list("Address", filters=filters, fields=["*"], order_by="creation asc")
|
||||
|
||||
address_list = [a.update({"display": get_address_display(a)}) for a in address_list]
|
||||
|
||||
|
|
|
|||
|
|
@ -152,8 +152,6 @@ class Communication(Document, CommunicationEmailMixin):
|
|||
if not email_body:
|
||||
return
|
||||
|
||||
email_body = email_body[0]
|
||||
|
||||
user_email_signature = (
|
||||
frappe.db.get_value(
|
||||
"User",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"view_settings",
|
||||
"title_field",
|
||||
"show_title_field_in_link",
|
||||
"translate_link_fields",
|
||||
"search_fields",
|
||||
"default_print_format",
|
||||
"sort_field",
|
||||
|
|
@ -591,6 +592,12 @@
|
|||
"fieldname": "show_title_field_in_link",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Title in Link Fields"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "translate_link_fields",
|
||||
"fieldtype": "Check",
|
||||
"label": "Translate Link Fields"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-bolt",
|
||||
|
|
@ -673,7 +680,7 @@
|
|||
"link_fieldname": "reference_doctype"
|
||||
}
|
||||
],
|
||||
"modified": "2022-02-15 21:47:16.467217",
|
||||
"modified": "2022-02-28 21:56:52.116915",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocType",
|
||||
|
|
@ -708,5 +715,6 @@
|
|||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
"track_changes": 1,
|
||||
"translate_link_fields": 1
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@ class DocType(Document):
|
|||
self.set_default_in_list_view()
|
||||
self.set_default_translatable()
|
||||
validate_series(self)
|
||||
self.set("can_change_name_type", validate_autoincrement_autoname(self))
|
||||
self.validate_document_type()
|
||||
validate_fields(self)
|
||||
|
||||
|
|
@ -124,12 +125,6 @@ class DocType(Document):
|
|||
if self.default_print_format and not self.custom:
|
||||
frappe.throw(_("Standard DocType cannot have default print format, use Customize Form"))
|
||||
|
||||
if check_if_can_change_name_type(self):
|
||||
change_name_column_type(
|
||||
self.name,
|
||||
"bigint" if self.autoname == "autoincrement" else f"varchar({frappe.db.VARCHAR_LEN})",
|
||||
)
|
||||
|
||||
def validate_field_name_conflicts(self):
|
||||
"""Check if field names dont conflict with controller properties and methods"""
|
||||
core_doctypes = [
|
||||
|
|
@ -374,6 +369,10 @@ class DocType(Document):
|
|||
|
||||
def on_update(self):
|
||||
"""Update database schema, make controller templates if `custom` is not set and clear cache."""
|
||||
|
||||
if self.get("can_change_name_type"):
|
||||
self.setup_autoincrement_and_sequence()
|
||||
|
||||
try:
|
||||
frappe.db.updatedb(self.name, Meta(self))
|
||||
except Exception as e:
|
||||
|
|
@ -413,6 +412,17 @@ class DocType(Document):
|
|||
|
||||
clear_linked_doctype_cache()
|
||||
|
||||
def setup_autoincrement_and_sequence(self):
|
||||
"""Changes name type and makes sequence on change (if required)"""
|
||||
|
||||
name_type = f"varchar({frappe.db.VARCHAR_LEN})"
|
||||
|
||||
if self.autoname == "autoincrement":
|
||||
name_type = "bigint"
|
||||
frappe.db.create_sequence(self.name, check_not_exists=True, cache=frappe.db.SEQUENCE_CACHE)
|
||||
|
||||
change_name_column_type(self.name, name_type)
|
||||
|
||||
def sync_global_search(self):
|
||||
"""If global search settings are changed, rebuild search properties for this table"""
|
||||
global_search_fields_before_update = [
|
||||
|
|
@ -903,26 +913,25 @@ def validate_series(dt, autoname=None, name=None):
|
|||
frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0]))
|
||||
|
||||
|
||||
def check_if_can_change_name_type(dt: DocType, raise_err: bool = True) -> bool:
|
||||
def get_autoname_before_save(doctype: str, to_be_customized_dt: str) -> str:
|
||||
if doctype == "Customize Form":
|
||||
property_value = frappe.db.get_value(
|
||||
"Property Setter", {"doc_type": to_be_customized_dt, "property": "autoname"}, "value"
|
||||
)
|
||||
def validate_autoincrement_autoname(dt: DocType) -> bool:
|
||||
"""Checks if can doctype can change to/from autoincrement autoname"""
|
||||
|
||||
def get_autoname_before_save(dt: DocType) -> str:
|
||||
if dt.name == "Customize Form":
|
||||
property_value = frappe.db.get_value(
|
||||
"Property Setter", {"doc_type": dt.doc_type, "property": "autoname"}, "value"
|
||||
)
|
||||
# initially no property setter is set,
|
||||
# hence getting autoname value from the doctype itself
|
||||
if not property_value:
|
||||
return frappe.db.get_value("DocType", to_be_customized_dt, "autoname") or ""
|
||||
return frappe.db.get_value("DocType", dt.doc_type, "autoname") or ""
|
||||
|
||||
return property_value
|
||||
|
||||
return getattr(dt.get_doc_before_save(), "autoname", "")
|
||||
|
||||
doctype_name = dt.doc_type if dt.doctype == "Customize Form" else dt.name
|
||||
|
||||
if not dt.is_new():
|
||||
autoname_before_save = get_autoname_before_save(dt.doctype, doctype_name)
|
||||
autoname_before_save = get_autoname_before_save(dt)
|
||||
is_autoname_autoincrement = dt.autoname == "autoincrement"
|
||||
|
||||
if (
|
||||
|
|
@ -930,23 +939,35 @@ def check_if_can_change_name_type(dt: DocType, raise_err: bool = True) -> bool:
|
|||
and autoname_before_save != "autoincrement"
|
||||
or (not is_autoname_autoincrement and autoname_before_save == "autoincrement")
|
||||
):
|
||||
if not frappe.get_all(doctype_name, limit=1):
|
||||
|
||||
if frappe.get_meta(dt.name).issingle:
|
||||
if dt.name == "Customize Form":
|
||||
frappe.throw(_("Cannot change to/from autoincrement autoname in Customize Form"))
|
||||
|
||||
return False
|
||||
|
||||
if not frappe.get_all(dt.name, limit=1):
|
||||
# allow changing the column type if there is no data
|
||||
return True
|
||||
|
||||
if raise_err:
|
||||
frappe.throw(
|
||||
_("Can only change to/from Autoincrement naming rule when there is no data in the doctype")
|
||||
)
|
||||
frappe.throw(
|
||||
_("Can only change to/from Autoincrement naming rule when there is no data in the doctype")
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def change_name_column_type(doctype_name: str, type: str) -> None:
|
||||
return frappe.db.change_column_type(
|
||||
doctype_name, "name", type, True if frappe.db.db_type == "mariadb" else False
|
||||
"""Changes name column type"""
|
||||
|
||||
args = (
|
||||
(doctype_name, "name", type, False, True)
|
||||
if (frappe.db.db_type == "postgres")
|
||||
else (doctype_name, "name", type, True)
|
||||
)
|
||||
|
||||
frappe.db.change_column_type(*args)
|
||||
|
||||
|
||||
def validate_links_table_fieldnames(meta):
|
||||
"""Validate fieldnames in Links table"""
|
||||
|
|
|
|||
|
|
@ -38,6 +38,52 @@ class TestDocType(unittest.TestCase):
|
|||
doc = new_doctype(name).insert()
|
||||
doc.delete()
|
||||
|
||||
def test_making_sequence_on_change(self):
|
||||
frappe.delete_doc_if_exists("DocType", self._testMethodName)
|
||||
dt = new_doctype(self._testMethodName).insert(ignore_permissions=True)
|
||||
autoname = dt.autoname
|
||||
|
||||
# change autoname
|
||||
dt.autoname = "autoincrement"
|
||||
dt.save()
|
||||
|
||||
# check if name type has been changed
|
||||
self.assertEqual(
|
||||
frappe.db.sql(
|
||||
f"""select data_type FROM information_schema.columns
|
||||
where column_name = 'name' and table_name = 'tab{self._testMethodName}'"""
|
||||
)[0][0],
|
||||
"bigint",
|
||||
)
|
||||
|
||||
if frappe.db.db_type == "mariadb":
|
||||
table_name = "information_schema.tables"
|
||||
conditions = f"table_type = 'sequence' and table_name = '{self._testMethodName}_id_seq'"
|
||||
else:
|
||||
table_name = "information_schema.sequences"
|
||||
conditions = f"sequence_name = '{self._testMethodName}_id_seq'"
|
||||
|
||||
# check if sequence table is created
|
||||
self.assertTrue(
|
||||
frappe.db.sql(
|
||||
f"""select * from {table_name}
|
||||
where {conditions}"""
|
||||
)
|
||||
)
|
||||
|
||||
# change the autoname/naming rule back to original
|
||||
dt.autoname = autoname
|
||||
dt.save()
|
||||
|
||||
# check if name type has changed
|
||||
self.assertEqual(
|
||||
frappe.db.sql(
|
||||
f"""select data_type FROM information_schema.columns
|
||||
where column_name = 'name' and table_name = 'tab{self._testMethodName}'"""
|
||||
)[0][0],
|
||||
"varchar" if frappe.db.db_type == "mariadb" else "character varying",
|
||||
)
|
||||
|
||||
def test_doctype_unique_constraint_dropped(self):
|
||||
if frappe.db.exists("DocType", "With_Unique"):
|
||||
frappe.delete_doc("DocType", "With_Unique")
|
||||
|
|
|
|||
|
|
@ -608,7 +608,7 @@ def on_doctype_update():
|
|||
def make_home_folder():
|
||||
home = frappe.get_doc(
|
||||
{"doctype": "File", "is_folder": 1, "is_home_folder": 1, "file_name": _("Home")}
|
||||
).insert()
|
||||
).insert(ignore_if_duplicate=True)
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
|
|
@ -618,7 +618,7 @@ def make_home_folder():
|
|||
"is_attachments_folder": 1,
|
||||
"file_name": _("Attachments"),
|
||||
}
|
||||
).insert()
|
||||
).insert(ignore_if_duplicate=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// MIT License. See license.txt
|
||||
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// MIT License. See LICENSE
|
||||
|
||||
frappe.ui.form.on('Role', {
|
||||
refresh: function(frm) {
|
||||
if (frm.doc.name === "All") {
|
||||
frm.dashboard.add_comment(
|
||||
__("Role 'All' will be given to all System Users."),
|
||||
"yellow"
|
||||
);
|
||||
}
|
||||
|
||||
frm.set_df_property('is_custom', 'read_only', frappe.session.user !== 'Administrator');
|
||||
|
||||
frm.add_custom_button("Role Permissions Manager", function() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Copyright (c) 2019, Frappe Technologies and contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
import ast
|
||||
from types import FunctionType, MethodType, ModuleType
|
||||
from typing import Dict, List
|
||||
|
||||
|
|
@ -17,6 +16,7 @@ class ServerScript(Document):
|
|||
frappe.only_for("Script Manager", True)
|
||||
self.sync_scheduled_jobs()
|
||||
self.clear_scheduled_events()
|
||||
self.check_if_compilable_in_restricted_context()
|
||||
|
||||
def on_update(self):
|
||||
frappe.cache().delete_value("server_script_map")
|
||||
|
|
@ -60,6 +60,15 @@ class ServerScript(Document):
|
|||
for scheduled_job in self.scheduled_jobs:
|
||||
frappe.delete_doc("Scheduled Job Type", scheduled_job.name)
|
||||
|
||||
def check_if_compilable_in_restricted_context(self):
|
||||
"""Check compilation errors and send them back as warnings."""
|
||||
from RestrictedPython import compile_restricted
|
||||
|
||||
try:
|
||||
compile_restricted(self.script)
|
||||
except Exception as e:
|
||||
frappe.msgprint(str(e), title=_("Compilation warning"))
|
||||
|
||||
def execute_method(self) -> Dict:
|
||||
"""Specific to API endpoint Server Scripts
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
"language",
|
||||
"column_break_3",
|
||||
"time_zone",
|
||||
"is_first_startup",
|
||||
"enable_onboarding",
|
||||
"setup_complete",
|
||||
"date_and_number_format",
|
||||
|
|
@ -72,7 +71,8 @@
|
|||
"column_break_64",
|
||||
"max_auto_email_report_per_user",
|
||||
"system_updates_section",
|
||||
"disable_system_update_notification"
|
||||
"disable_system_update_notification",
|
||||
"disable_change_log_notification"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -104,14 +104,6 @@
|
|||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_first_startup",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is First Startup",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "setup_complete",
|
||||
|
|
@ -505,12 +497,18 @@
|
|||
"fieldname": "max_auto_email_report_per_user",
|
||||
"fieldtype": "Int",
|
||||
"label": "Max auto email report per user"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disable_change_log_notification",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable Change Log Notification"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cog",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2022-04-21 09:11:35.218721",
|
||||
"modified": "2022-05-09 18:53:35.218721",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "System Settings",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ frappe.ui.form.on("Customize Form", {
|
|||
onload: function(frm) {
|
||||
frm.set_query("doc_type", function() {
|
||||
return {
|
||||
translate_values: false,
|
||||
filters: [
|
||||
["DocType", "issingle", "=", 0],
|
||||
["DocType", "custom", "=", 0],
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"view_settings_section",
|
||||
"title_field",
|
||||
"show_title_field_in_link",
|
||||
"translate_link_fields",
|
||||
"image_field",
|
||||
"default_print_format",
|
||||
"column_break_29",
|
||||
|
|
@ -287,7 +288,7 @@
|
|||
"label": "Naming"
|
||||
},
|
||||
{
|
||||
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li>\n<li><b>autoincrement</b> - Uses Databases' Auto Increment feature</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
|
||||
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present)</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
|
||||
"fieldname": "autoname",
|
||||
"fieldtype": "Data",
|
||||
"label": "Auto Name"
|
||||
|
|
@ -311,6 +312,12 @@
|
|||
"fieldname": "show_title_field_in_link",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Title in Link Fields"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "translate_link_fields",
|
||||
"fieldtype": "Check",
|
||||
"label": "Translate Link Fields"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
|
|
@ -319,7 +326,7 @@
|
|||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2022-04-21 15:36:16.772277",
|
||||
"modified": "2022-05-13 15:36:16.772277",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom",
|
||||
"name": "Customize Form",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ import frappe
|
|||
import frappe.translate
|
||||
from frappe import _
|
||||
from frappe.core.doctype.doctype.doctype import (
|
||||
change_name_column_type,
|
||||
check_email_append_to,
|
||||
check_if_can_change_name_type,
|
||||
validate_autoincrement_autoname,
|
||||
validate_fields_for_doctype,
|
||||
validate_series,
|
||||
)
|
||||
|
|
@ -163,7 +162,7 @@ class CustomizeForm(Document):
|
|||
return
|
||||
|
||||
validate_series(self, self.autoname, self.doc_type)
|
||||
can_change_name_type = check_if_can_change_name_type(self)
|
||||
validate_autoincrement_autoname(self)
|
||||
self.flags.update_db = False
|
||||
self.flags.rebuild_doctype_for_global_search = False
|
||||
self.set_property_setters()
|
||||
|
|
@ -172,12 +171,6 @@ class CustomizeForm(Document):
|
|||
validate_fields_for_doctype(self.doc_type)
|
||||
check_email_append_to(self)
|
||||
|
||||
if can_change_name_type:
|
||||
change_name_column_type(
|
||||
self.doc_type,
|
||||
"bigint" if self.autoname == "autoincrement" else f"varchar({frappe.db.VARCHAR_LEN})",
|
||||
)
|
||||
|
||||
if self.flags.update_db:
|
||||
frappe.db.updatedb(self.doc_type)
|
||||
|
||||
|
|
@ -584,6 +577,7 @@ doctype_properties = {
|
|||
"naming_rule": "Data",
|
||||
"autoname": "Data",
|
||||
"show_title_field_in_link": "Check",
|
||||
"translate_link_fields": "Check",
|
||||
}
|
||||
|
||||
docfield_properties = {
|
||||
|
|
|
|||
|
|
@ -396,3 +396,10 @@ class TestCustomizeForm(unittest.TestCase):
|
|||
d.label = ""
|
||||
d.run_method("save_customization")
|
||||
self.assertEqual(d.label, "")
|
||||
|
||||
def test_change_to_autoincrement_autoname(self):
|
||||
d = self.get_customize_form("Event")
|
||||
d.autoname = "autoincrement"
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
d.run_method("save_customization")
|
||||
|
|
|
|||
|
|
@ -1019,13 +1019,13 @@ class Database(object):
|
|||
|
||||
return self.get_value(dt, dn, ignore=True, cache=cache)
|
||||
|
||||
def count(self, dt, filters=None, debug=False, cache=False):
|
||||
def count(self, dt, filters=None, debug=False, cache=False, distinct: bool = True):
|
||||
"""Returns `COUNT(*)` for given DocType and filters."""
|
||||
if cache and not filters:
|
||||
cache_count = frappe.cache().get_value("doctype:count:{}".format(dt))
|
||||
if cache_count is not None:
|
||||
return cache_count
|
||||
query = self.query.get_sql(table=dt, filters=filters, fields=Count("*"))
|
||||
query = self.query.get_sql(table=dt, filters=filters, fields=Count("*"), distinct=distinct)
|
||||
if filters:
|
||||
count = self.sql(query, debug=debug)[0][0]
|
||||
return count
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ class MariaDBDatabase(Database):
|
|||
DataError = pymysql.err.DataError
|
||||
REGEX_CHARACTER = "regexp"
|
||||
|
||||
# NOTE: using a very small cache - as during backup, if the sequence was used in anyform,
|
||||
# it drops the cache and uses the next non cached value in setval query and
|
||||
# puts that in the backup file, which will start the counter
|
||||
# from that value when inserting any new record in the doctype.
|
||||
# By default the cache is 1000 which will mess up the sequence when
|
||||
# using the system after a restore.
|
||||
# issue link: https://jira.mariadb.org/browse/MDEV-21786
|
||||
SEQUENCE_CACHE = 50
|
||||
|
||||
def setup_type_map(self):
|
||||
self.db_type = "mariadb"
|
||||
self.type_map = {
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ CREATE TABLE `tabDocType` (
|
|||
`sender_field` varchar(255) DEFAULT NULL,
|
||||
`show_title_field_in_link` int(1) NOT NULL DEFAULT 0,
|
||||
`migration_hash` varchar(255) DEFAULT NULL,
|
||||
`translate_link_fields` int(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`name`)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
|
|
|||
|
|
@ -40,14 +40,7 @@ class MariaDBTable(DBTable):
|
|||
not self.meta.issingle and self.meta.autoname == "autoincrement"
|
||||
) or self.doctype in log_types:
|
||||
|
||||
# NOTE: using a very small cache - as during backup, if the sequence was used in anyform,
|
||||
# it drops the cache and uses the next non cached value in setval func and
|
||||
# puts that in the backup file, which will start the counter
|
||||
# from that value when inserting any new record in the doctype.
|
||||
# By default the cache is 1000 which will mess up the sequence when
|
||||
# using the system after a restore.
|
||||
# issue link: https://jira.mariadb.org/browse/MDEV-21786
|
||||
frappe.db.create_sequence(self.doctype, check_not_exists=True, cache=50)
|
||||
frappe.db.create_sequence(self.doctype, check_not_exists=True, cache=frappe.db.SEQUENCE_CACHE)
|
||||
|
||||
# NOTE: not used nextval func as default as the ability to restore
|
||||
# database with sequences has bugs in mariadb and gives a scary error.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ class PostgresDatabase(Database):
|
|||
InterfaceError = psycopg2.InterfaceError
|
||||
REGEX_CHARACTER = "~"
|
||||
|
||||
# NOTE; The sequence cache for postgres is per connection.
|
||||
# Since we're opening and closing connections for every transaction this results in skipping the cache
|
||||
# to the next non-cached value hence not using cache in postgres.
|
||||
# ref: https://stackoverflow.com/questions/21356375/postgres-9-0-4-sequence-skipping-numbers
|
||||
SEQUENCE_CACHE = 0
|
||||
|
||||
def setup_type_map(self):
|
||||
self.db_type = "postgres"
|
||||
self.type_map = {
|
||||
|
|
@ -209,18 +215,19 @@ class PostgresDatabase(Database):
|
|||
)
|
||||
|
||||
def change_column_type(
|
||||
self, doctype: str, column: str, type: str, nullable: bool = False
|
||||
self, doctype: str, column: str, type: str, nullable: bool = False, use_cast: bool = False
|
||||
) -> Union[List, Tuple]:
|
||||
table_name = get_table_name(doctype)
|
||||
null_constraint = "SET NOT NULL" if not nullable else "DROP NOT NULL"
|
||||
using_cast = f'using "{column}"::{type}' if use_cast else ""
|
||||
|
||||
# postgres allows ddl in transactions but since we've currently made
|
||||
# things same as mariadb (raising exception on ddl commands if the transaction has any writes),
|
||||
# hence using sql_ddl here for committing and then moving forward.
|
||||
return self.sql_ddl(
|
||||
f"""ALTER TABLE "{table_name}"
|
||||
ALTER COLUMN "{column}" TYPE {type},
|
||||
ALTER COLUMN "{column}" {null_constraint}"""
|
||||
ALTER COLUMN "{column}" TYPE {type} {using_cast},
|
||||
ALTER COLUMN "{column}" {null_constraint}"""
|
||||
)
|
||||
|
||||
def create_auth_table(self):
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ CREATE TABLE "tabDocType" (
|
|||
"sender_field" varchar(255) DEFAULT NULL,
|
||||
"show_title_field_in_link" smallint NOT NULL DEFAULT 0,
|
||||
"migration_hash" varchar(255) DEFAULT NULL,
|
||||
"translate_link_fields" smallint NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY ("name")
|
||||
) ;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,11 +34,7 @@ class PostgresTable(DBTable):
|
|||
not self.meta.issingle and self.meta.autoname == "autoincrement"
|
||||
) or self.doctype in log_types:
|
||||
|
||||
# The sequence cache is per connection.
|
||||
# Since we're opening and closing connections for every transaction this results in skipping the cache
|
||||
# to the next non-cached value hence not using cache in postgres.
|
||||
# ref: https://stackoverflow.com/questions/21356375/postgres-9-0-4-sequence-skipping-numbers
|
||||
frappe.db.create_sequence(self.doctype, check_not_exists=True)
|
||||
frappe.db.create_sequence(self.doctype, check_not_exists=True, cache=frappe.db.SEQUENCE_CACHE)
|
||||
name_column = "name bigint primary key"
|
||||
|
||||
# TODO: set docstatus length
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ from typing import Any, Dict, List, Tuple, Union
|
|||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Criterion, Field, Order
|
||||
from frappe.query_builder import Criterion, Field, Order, Table
|
||||
|
||||
|
||||
def like(key: str, value: str) -> frappe.qb:
|
||||
def like(key: Field, value: str) -> frappe.qb:
|
||||
"""Wrapper method for `LIKE`
|
||||
|
||||
Args:
|
||||
|
|
@ -17,10 +17,10 @@ def like(key: str, value: str) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `LIKE`
|
||||
"""
|
||||
return Field(key).like(value)
|
||||
return key.like(value)
|
||||
|
||||
|
||||
def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb:
|
||||
def func_in(key: Field, value: Union[List, Tuple]) -> frappe.qb:
|
||||
"""Wrapper method for `IN`
|
||||
|
||||
Args:
|
||||
|
|
@ -30,10 +30,10 @@ def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `IN`
|
||||
"""
|
||||
return Field(key).isin(value)
|
||||
return key.isin(value)
|
||||
|
||||
|
||||
def not_like(key: str, value: str) -> frappe.qb:
|
||||
def not_like(key: Field, value: str) -> frappe.qb:
|
||||
"""Wrapper method for `NOT LIKE`
|
||||
|
||||
Args:
|
||||
|
|
@ -43,10 +43,10 @@ def not_like(key: str, value: str) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `NOT LIKE`
|
||||
"""
|
||||
return Field(key).not_like(value)
|
||||
return key.not_like(value)
|
||||
|
||||
|
||||
def func_not_in(key: str, value: Union[List, Tuple]):
|
||||
def func_not_in(key: Field, value: Union[List, Tuple]):
|
||||
"""Wrapper method for `NOT IN`
|
||||
|
||||
Args:
|
||||
|
|
@ -56,10 +56,10 @@ def func_not_in(key: str, value: Union[List, Tuple]):
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `NOT IN`
|
||||
"""
|
||||
return Field(key).notin(value)
|
||||
return key.notin(value)
|
||||
|
||||
|
||||
def func_regex(key: str, value: str) -> frappe.qb:
|
||||
def func_regex(key: Field, value: str) -> frappe.qb:
|
||||
"""Wrapper method for `REGEX`
|
||||
|
||||
Args:
|
||||
|
|
@ -69,10 +69,10 @@ def func_regex(key: str, value: str) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `REGEX`
|
||||
"""
|
||||
return Field(key).regex(value)
|
||||
return key.regex(value)
|
||||
|
||||
|
||||
def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb:
|
||||
def func_between(key: Field, value: Union[List, Tuple]) -> frappe.qb:
|
||||
"""Wrapper method for `BETWEEN`
|
||||
|
||||
Args:
|
||||
|
|
@ -82,7 +82,12 @@ def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `BETWEEN`
|
||||
"""
|
||||
return Field(key)[slice(*value)]
|
||||
return key[slice(*value)]
|
||||
|
||||
|
||||
def func_is(key, value):
|
||||
"Wrapper for IS"
|
||||
return Field(key).isnotnull() if value.lower() == "set" else Field(key).isnull()
|
||||
|
||||
|
||||
def make_function(key: Any, value: Union[int, str]):
|
||||
|
|
@ -135,11 +140,14 @@ OPERATOR_MAP = {
|
|||
"not like": not_like,
|
||||
"regex": func_regex,
|
||||
"between": func_between,
|
||||
"is": func_is,
|
||||
}
|
||||
|
||||
|
||||
class Query:
|
||||
def get_condition(self, table: str, **kwargs) -> frappe.qb:
|
||||
tables: dict = {}
|
||||
|
||||
def get_condition(self, table: Union[str, Table], **kwargs) -> frappe.qb:
|
||||
"""Get initial table object
|
||||
|
||||
Args:
|
||||
|
|
@ -148,11 +156,20 @@ class Query:
|
|||
Returns:
|
||||
frappe.qb: DocType with initial condition
|
||||
"""
|
||||
table_object = self.get_table(table)
|
||||
if kwargs.get("update"):
|
||||
return frappe.qb.update(table)
|
||||
return frappe.qb.update(table_object)
|
||||
if kwargs.get("into"):
|
||||
return frappe.qb.into(table)
|
||||
return frappe.qb.from_(table)
|
||||
return frappe.qb.into(table_object)
|
||||
return frappe.qb.from_(table_object)
|
||||
|
||||
def get_table(self, table_name: Union[str, Table]) -> Table:
|
||||
if isinstance(table_name, Table):
|
||||
return table_name
|
||||
table_name = table_name.strip('"').strip("'")
|
||||
if table_name not in self.tables:
|
||||
self.tables[table_name] = frappe.qb.DocType(table_name)
|
||||
return self.tables[table_name]
|
||||
|
||||
def criterion_query(self, table: str, criterion: Criterion, **kwargs) -> frappe.qb:
|
||||
"""Generate filters from Criterion objects
|
||||
|
|
@ -217,8 +234,13 @@ class Query:
|
|||
conditions = conditions.where(_operator(Field(filters[0]), filters[2]))
|
||||
break
|
||||
else:
|
||||
_operator = OPERATOR_MAP[f[1]]
|
||||
conditions = conditions.where(_operator(Field(f[0]), f[2]))
|
||||
_operator = OPERATOR_MAP[f[-2]]
|
||||
if len(f) == 4:
|
||||
table_object = self.get_table(f[0])
|
||||
_field = table_object[f[1]]
|
||||
else:
|
||||
_field = Field(f[0])
|
||||
conditions = conditions.where(_operator(_field, f[-1]))
|
||||
|
||||
return self.add_conditions(conditions, **kwargs)
|
||||
|
||||
|
|
@ -249,7 +271,7 @@ class Query:
|
|||
if isinstance(value, (list, tuple)):
|
||||
if isinstance(value[1], (list, tuple)) or value[0] in list(OPERATOR_MAP.keys())[-4:]:
|
||||
_operator = OPERATOR_MAP[value[0]]
|
||||
conditions = conditions.where(_operator(key, value[1]))
|
||||
conditions = conditions.where(_operator(Field(key), value[1]))
|
||||
else:
|
||||
_operator = OPERATOR_MAP[value[0]]
|
||||
conditions = conditions.where(_operator(Field(key), value[1]))
|
||||
|
|
@ -293,10 +315,19 @@ class Query:
|
|||
self,
|
||||
table: str,
|
||||
fields: Union[List, Tuple],
|
||||
filters: Union[Dict[str, Union[str, int]], str, int] = None,
|
||||
filters: Union[Dict[str, Union[str, int]], str, int, List[Union[List, str, int]]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
# Clean up state before each query
|
||||
self.tables = {}
|
||||
criterion = self.build_conditions(table, filters, **kwargs)
|
||||
|
||||
if len(self.tables) > 1:
|
||||
primary_table = self.tables[table]
|
||||
del self.tables[table]
|
||||
for table_object in self.tables.values():
|
||||
criterion = criterion.left_join(table_object).on(table_object.parent == primary_table.name)
|
||||
|
||||
if isinstance(fields, (list, tuple)):
|
||||
query = criterion.select(*kwargs.get("field_objects", fields))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ def create_sequence(
|
|||
doctype_name: str,
|
||||
*,
|
||||
slug: str = "_id_seq",
|
||||
temporary=False,
|
||||
temporary: bool = False,
|
||||
check_not_exists: bool = False,
|
||||
cycle: bool = False,
|
||||
cache: int = 0,
|
||||
|
|
@ -51,7 +51,7 @@ def create_sequence(
|
|||
else:
|
||||
query += " cycle"
|
||||
|
||||
db.sql(query)
|
||||
db.sql_ddl(query)
|
||||
|
||||
return sequence_name
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ class Workspace:
|
|||
|
||||
self.onboardings = {"items": self.get_onboardings()}
|
||||
|
||||
self.quick_lists = {"items": self.get_quick_lists()}
|
||||
|
||||
def _doctype_contains_a_record(self, name):
|
||||
exists = self.table_counts.get(name, False)
|
||||
|
||||
|
|
@ -284,6 +286,21 @@ class Workspace:
|
|||
|
||||
return items
|
||||
|
||||
@handle_not_exist
|
||||
def get_quick_lists(self):
|
||||
items = []
|
||||
quick_lists = self.doc.quick_lists
|
||||
|
||||
for item in quick_lists:
|
||||
new_item = item.as_dict().copy()
|
||||
|
||||
# Translate label
|
||||
new_item["label"] = _(item.label) if item.label else _(item.document_type)
|
||||
|
||||
items.append(new_item)
|
||||
|
||||
return items
|
||||
|
||||
@handle_not_exist
|
||||
def get_onboardings(self):
|
||||
if self.onboarding_list:
|
||||
|
|
@ -336,6 +353,7 @@ def get_desktop_page(page):
|
|||
"shortcuts": workspace.shortcuts,
|
||||
"cards": workspace.cards,
|
||||
"onboardings": workspace.onboardings,
|
||||
"quick_lists": workspace.quick_lists,
|
||||
}
|
||||
except DoesNotExistError:
|
||||
frappe.log_error("Workspace Missing")
|
||||
|
|
@ -452,6 +470,8 @@ def save_new_widget(doc, page, blocks, new_widgets):
|
|||
doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts"))
|
||||
if widgets.shortcut:
|
||||
doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts"))
|
||||
if widgets.quick_list:
|
||||
doc.quick_lists.extend(new_widget(widgets.quick_list, "Workspace Quick List", "quick_lists"))
|
||||
if widgets.card:
|
||||
doc.build_links_table_from_card(widgets.card)
|
||||
|
||||
|
|
@ -481,12 +501,12 @@ def save_new_widget(doc, page, blocks, new_widgets):
|
|||
def clean_up(original_page, blocks):
|
||||
page_widgets = {}
|
||||
|
||||
for wid in ["shortcut", "card", "chart"]:
|
||||
for wid in ["shortcut", "card", "chart", "quick_list"]:
|
||||
# get list of widget's name from blocks
|
||||
page_widgets[wid] = [x["data"][wid + "_name"] for x in loads(blocks) if x["type"] == wid]
|
||||
|
||||
# shortcut & chart cleanup
|
||||
for wid in ["shortcut", "chart"]:
|
||||
# shortcut, chart & quick_list cleanup
|
||||
for wid in ["shortcut", "chart", "quick_list"]:
|
||||
updated_widgets = []
|
||||
original_page.get(wid + "s").reverse()
|
||||
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@
|
|||
"icon": "fa fa-calendar",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2021-11-18 05:06:24.881742",
|
||||
"modified": "2022-05-12 05:43:27.935510",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "Event",
|
||||
|
|
@ -312,6 +312,7 @@
|
|||
"sender_field": "sender",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"subject_field": "subject",
|
||||
"title_field": "subject",
|
||||
"track_changes": 1,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
"shortcuts",
|
||||
"tab_break_18",
|
||||
"links",
|
||||
"quick_lists_tab",
|
||||
"quick_lists",
|
||||
"roles_tab",
|
||||
"roles"
|
||||
],
|
||||
|
|
@ -155,11 +157,22 @@
|
|||
"fieldname": "roles_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Roles"
|
||||
},
|
||||
{
|
||||
"fieldname": "quick_lists_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Quick Lists"
|
||||
},
|
||||
{
|
||||
"fieldname": "quick_lists",
|
||||
"fieldtype": "Table",
|
||||
"label": "Quick Lists",
|
||||
"options": "Workspace Quick List"
|
||||
}
|
||||
],
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2022-01-27 12:06:13.111743",
|
||||
"modified": "2022-05-12 13:00:03.925605",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "Workspace",
|
||||
|
|
@ -189,5 +202,6 @@
|
|||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
0
frappe/desk/doctype/workspace_quick_list/__init__.py
Normal file
0
frappe/desk/doctype/workspace_quick_list/__init__.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"actions": [],
|
||||
"creation": "2022-05-12 12:58:41.824496",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"document_type",
|
||||
"column_break_1",
|
||||
"label",
|
||||
"section_break_4",
|
||||
"quick_list_filter"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "document_type",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "DocType",
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_1",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "label",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Label",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_4",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "quick_list_filter",
|
||||
"fieldtype": "Code",
|
||||
"in_list_view": 1,
|
||||
"label": "Quick List Filter",
|
||||
"options": "JSON"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-05-12 13:48:40.617623",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "Workspace Quick List",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Copyright (c) 2022, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class WorkspaceQuickList(Document):
|
||||
pass
|
||||
|
|
@ -249,9 +249,9 @@ def get_open_count(doctype, name, items=None):
|
|||
if frappe.flags.in_migrate or frappe.flags.in_install:
|
||||
return {"count": []}
|
||||
|
||||
frappe.has_permission(doc=frappe.get_doc(doctype, name), throw=True)
|
||||
|
||||
meta = frappe.get_meta(doctype)
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.check_permission()
|
||||
meta = doc.meta
|
||||
links = meta.get_dashboard_data()
|
||||
|
||||
# compile all items in a list
|
||||
|
|
@ -266,7 +266,6 @@ def get_open_count(doctype, name, items=None):
|
|||
out = []
|
||||
for d in items:
|
||||
if d in links.get("internal_links", {}):
|
||||
# internal link
|
||||
continue
|
||||
|
||||
filters = get_filters_for(d)
|
||||
|
|
|
|||
|
|
@ -269,7 +269,6 @@ def add_all_roles_to(name):
|
|||
def disable_future_access():
|
||||
frappe.db.set_default("desktop:home_page", "workspace")
|
||||
frappe.db.set_value("System Settings", "System Settings", "setup_complete", 1)
|
||||
frappe.db.set_value("System Settings", "System Settings", "is_first_startup", 1)
|
||||
|
||||
# Enable onboarding after install
|
||||
frappe.db.set_value("System Settings", "System Settings", "enable_onboarding", 1)
|
||||
|
|
@ -334,11 +333,6 @@ def load_user_details():
|
|||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reset_is_first_startup():
|
||||
frappe.db.set_value("System Settings", "System Settings", "is_first_startup", 0)
|
||||
|
||||
|
||||
def prettify_args(args):
|
||||
# remove attachments
|
||||
for key, val in args.items():
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ ALLOWED_MIMETYPES = (
|
|||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/plain",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -202,7 +203,7 @@ def upload_file():
|
|||
if not file_url and (frappe.session.user == "Guest" or (user and not user.has_desk_access())):
|
||||
filetype = guess_type(filename)[0]
|
||||
if filetype not in ALLOWED_MIMETYPES:
|
||||
frappe.throw(_("You can only upload JPG, PNG, PDF, or Microsoft documents."))
|
||||
frappe.throw(_("You can only upload JPG, PNG, PDF, TXT or Microsoft documents."))
|
||||
|
||||
if method:
|
||||
method = frappe.get_attr(method)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ app_name = "frappe"
|
|||
app_title = "Frappe Framework"
|
||||
app_publisher = "Frappe Technologies"
|
||||
app_description = "Full stack web framework with Python, Javascript, MariaDB, Redis, Node"
|
||||
app_icon = "octicon octicon-circuit-board"
|
||||
app_color = "orange"
|
||||
source_link = "https://github.com/frappe/frappe"
|
||||
app_license = "MIT"
|
||||
app_logo_url = "/assets/frappe/images/frappe-framework-logo.svg"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import sys
|
|||
from collections import OrderedDict
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import click
|
||||
|
||||
import frappe
|
||||
from frappe.defaults import _clear_cache
|
||||
from frappe.utils import is_git_url
|
||||
|
|
@ -80,7 +82,13 @@ def _new_site(
|
|||
)
|
||||
|
||||
for app in apps_to_install:
|
||||
install_app(app, verbose=verbose, set_as_patched=not source_sql)
|
||||
# NOTE: not using force here for 2 reasons:
|
||||
# 1. It's not really needed here as we've freshly installed a new db
|
||||
# 2. If someone uses a sql file to do restore and that file already had
|
||||
# installed_apps then it might cause problems as that sql file can be of any previous version(s)
|
||||
# which might be incompatible with the current version and using force might cause problems.
|
||||
# Example: the DocType DocType might not have `migration_hash` column which will cause failure in the restore.
|
||||
install_app(app, verbose=verbose, set_as_patched=not source_sql, force=False)
|
||||
|
||||
os.remove(installing)
|
||||
|
||||
|
|
@ -226,7 +234,7 @@ def parse_app_name(name: str) -> str:
|
|||
return repo
|
||||
|
||||
|
||||
def install_app(name, verbose=False, set_as_patched=True):
|
||||
def install_app(name, verbose=False, set_as_patched=True, force=False):
|
||||
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
|
||||
from frappe.model.sync import sync_for
|
||||
from frappe.modules.utils import sync_customizations
|
||||
|
|
@ -243,7 +251,7 @@ def install_app(name, verbose=False, set_as_patched=True):
|
|||
if app_hooks.required_apps:
|
||||
for app in app_hooks.required_apps:
|
||||
required_app = parse_app_name(app)
|
||||
install_app(required_app, verbose=verbose)
|
||||
install_app(required_app, verbose=verbose, force=force)
|
||||
|
||||
frappe.flags.in_install = name
|
||||
frappe.clear_cache()
|
||||
|
|
@ -251,8 +259,8 @@ def install_app(name, verbose=False, set_as_patched=True):
|
|||
if name not in frappe.get_all_apps():
|
||||
raise Exception("App not in apps.txt")
|
||||
|
||||
if name in installed_apps:
|
||||
frappe.msgprint(frappe._("App {0} already installed").format(name))
|
||||
if not force and name in installed_apps:
|
||||
click.secho(f"App {name} already installed", fg="yellow")
|
||||
return
|
||||
|
||||
print("\nInstalling {0}...".format(name))
|
||||
|
|
@ -266,9 +274,9 @@ def install_app(name, verbose=False, set_as_patched=True):
|
|||
return
|
||||
|
||||
if name != "frappe":
|
||||
add_module_defs(name)
|
||||
add_module_defs(name, ignore_if_duplicate=force)
|
||||
|
||||
sync_for(name, force=True, reset_permissions=True)
|
||||
sync_for(name, force=force, reset_permissions=True)
|
||||
|
||||
add_to_installed_apps(name)
|
||||
|
||||
|
|
@ -315,7 +323,6 @@ def remove_from_installed_apps(app_name):
|
|||
|
||||
def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False):
|
||||
"""Remove app and all linked to the app's module with the app from a site."""
|
||||
import click
|
||||
|
||||
site = frappe.local.site
|
||||
app_hooks = frappe.get_hooks(app_name=app_name)
|
||||
|
|
@ -573,13 +580,13 @@ def make_site_dirs():
|
|||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def add_module_defs(app):
|
||||
def add_module_defs(app, ignore_if_duplicate=False):
|
||||
modules = frappe.get_module_list(app)
|
||||
for module in modules:
|
||||
d = frappe.new_doc("Module Def")
|
||||
d.app_name = app
|
||||
d.module_name = module
|
||||
d.insert(ignore_permissions=True, ignore_if_duplicate=True)
|
||||
d.insert(ignore_permissions=True, ignore_if_duplicate=ignore_if_duplicate)
|
||||
|
||||
|
||||
def remove_missing_apps():
|
||||
|
|
@ -752,11 +759,9 @@ def partial_restore(sql_file_path, verbose=False):
|
|||
elif frappe.conf.db_type == "postgres":
|
||||
import warnings
|
||||
|
||||
from click import style
|
||||
|
||||
from frappe.database.postgres.setup_db import import_db_from_sql
|
||||
|
||||
warn = style(
|
||||
warn = click.style(
|
||||
"Delete the tables you want to restore manually before attempting"
|
||||
" partial restore operation for PostreSQL databases",
|
||||
fg="yellow",
|
||||
|
|
@ -798,8 +803,6 @@ def validate_database_sql(path, _raise=True):
|
|||
error_message = "Table `tabDefaultValue` not found in file."
|
||||
|
||||
if error_message:
|
||||
import click
|
||||
|
||||
click.secho(error_message, fg="red")
|
||||
|
||||
if _raise and (missing_table or empty_file):
|
||||
|
|
|
|||
|
|
@ -920,12 +920,12 @@ def cast_name(column: str) -> str:
|
|||
|
||||
kwargs = {"string": column, "flags": re.IGNORECASE}
|
||||
if "cast(" not in column.lower() and "::" not in column:
|
||||
if re.search(r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\s*\)", **kwargs):
|
||||
if re.search(r"locate\([^,]+,\s*[`\"]?name[`\"]?\s*\)", **kwargs):
|
||||
return re.sub(
|
||||
r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\)", r"locate(\1, cast(\2 as varchar))", **kwargs
|
||||
r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\s*\)", r"locate(\1, cast(\2 as varchar))", **kwargs
|
||||
)
|
||||
|
||||
elif match := re.search(r"(strpos|ifnull|coalesce)\(\s*([`\"]?name[`\"]?)\s*,", **kwargs):
|
||||
elif match := re.search(r"(strpos|ifnull|coalesce)\(\s*[`\"]?name[`\"]?\s*,", **kwargs):
|
||||
func = match.groups()[0]
|
||||
return re.sub(rf"{func}\(\s*([`\"]?name[`\"]?)\s*,", rf"{func}(cast(\1 as varchar),", **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ def sync_for(app_name, force=0, reset_permissions=False):
|
|||
"workspace_link",
|
||||
"workspace_chart",
|
||||
"workspace_shortcut",
|
||||
"workspace_quick_list",
|
||||
"workspace",
|
||||
]:
|
||||
files.append(os.path.join(FRAPPE_PATH, "desk", "doctype", desk_module, f"{desk_module}.json"))
|
||||
|
|
|
|||
|
|
@ -144,7 +144,6 @@ def import_file_by_path(
|
|||
|
||||
import_doc(
|
||||
docdict=doc,
|
||||
force=force,
|
||||
data_import=data_import,
|
||||
pre_process=pre_process,
|
||||
ignore_version=ignore_version,
|
||||
|
|
@ -203,7 +202,6 @@ def update_modified(original_modified, doc):
|
|||
|
||||
def import_doc(
|
||||
docdict,
|
||||
force=False,
|
||||
data_import=False,
|
||||
pre_process=None,
|
||||
ignore_version=None,
|
||||
|
|
|
|||
|
|
@ -189,6 +189,8 @@ frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021
|
|||
frappe.patches.v14_0.transform_todo_schema
|
||||
frappe.patches.v14_0.remove_post_and_post_comment
|
||||
frappe.patches.v14_0.reset_creation_datetime
|
||||
frappe.patches.v14_0.remove_is_first_startup
|
||||
frappe.patches.v14_0.reload_workspace_child_tables
|
||||
|
||||
[post_model_sync]
|
||||
frappe.patches.v14_0.drop_data_import_legacy
|
||||
|
|
|
|||
13
frappe/patches/v14_0/reload_workspace_child_tables.py
Normal file
13
frappe/patches/v14_0/reload_workspace_child_tables.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
child_tables = frappe.get_all(
|
||||
"DocField",
|
||||
pluck="options",
|
||||
filters={"fieldtype": ["in", frappe.model.table_fields], "parent": "Workspace"},
|
||||
)
|
||||
|
||||
for child_table in child_tables:
|
||||
if child_table != "Has Role":
|
||||
frappe.reload_doc("desk", "doctype", child_table, force=True)
|
||||
8
frappe/patches/v14_0/remove_is_first_startup.py
Normal file
8
frappe/patches/v14_0/remove_is_first_startup.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
singles = frappe.qb.Table("tabSingles")
|
||||
frappe.qb.from_(singles).delete().where(
|
||||
(singles.doctype == "System Settings") & (singles.field == "is_first_startup")
|
||||
).run()
|
||||
|
|
@ -511,7 +511,8 @@ frappe.Application = class Application {
|
|||
// "version": "12.2.0"
|
||||
// }];
|
||||
|
||||
if (!Array.isArray(change_log) || !change_log.length || window.Cypress) {
|
||||
if (!Array.isArray(change_log) || !change_log.length ||
|
||||
window.Cypress || cint(frappe.boot.sysdefaults.disable_change_log_notification)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,11 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control
|
|||
};
|
||||
|
||||
var update_input = function() {
|
||||
me.set_input(me.value);
|
||||
if (me.doctype && me.docname) {
|
||||
me.set_input(me.value);
|
||||
} else {
|
||||
me.set_input(me.value || null);
|
||||
}
|
||||
};
|
||||
|
||||
if (me.disp_status != "None") {
|
||||
|
|
@ -155,6 +159,13 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control
|
|||
this.$wrapper.find(".help-box").html("");
|
||||
}
|
||||
set_mandatory(value) {
|
||||
// do not set has-error class on form load
|
||||
if (this.frm && this.frm.cscript && this.frm.cscript.is_onload) return;
|
||||
|
||||
// do not set has-error class while dialog is rendered
|
||||
// set has-error if dialog primary button is clicked
|
||||
if (this.layout && this.layout.is_dialog && !this.layout.primary_action_fulfilled) return;
|
||||
|
||||
this.$wrapper.toggleClass("has-error", Boolean(this.df.reqd && is_null(value)));
|
||||
}
|
||||
set_invalid () {
|
||||
|
|
|
|||
|
|
@ -137,13 +137,13 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat
|
|||
});
|
||||
}
|
||||
parse(value) {
|
||||
if(value) {
|
||||
return frappe.datetime.user_to_str(value);
|
||||
if (value) {
|
||||
return frappe.datetime.user_to_str(value, false, true);
|
||||
}
|
||||
}
|
||||
format_for_input(value) {
|
||||
if(value) {
|
||||
return frappe.datetime.str_to_user(value);
|
||||
if (value) {
|
||||
return frappe.datetime.str_to_user(value, false, true);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co
|
|||
}
|
||||
format_for_input(value) {
|
||||
if (!value) return "";
|
||||
|
||||
|
||||
return frappe.datetime.str_to_user(value, false);
|
||||
}
|
||||
set_description() {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
|
||||
if(!me.$input.val()) {
|
||||
me.$input.val("").trigger("input");
|
||||
|
||||
// hide link arrow to doctype if none is set
|
||||
me.$link.toggle(false);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
|
@ -78,6 +81,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
}
|
||||
this.set_link_title(value);
|
||||
}
|
||||
get_translated(value) {
|
||||
return this.is_translatable() ? __(value) : value;
|
||||
}
|
||||
is_translatable() {
|
||||
return in_list(frappe.boot?.translatable_doctypes || [], this.get_options());
|
||||
}
|
||||
set_link_title(value) {
|
||||
let doctype = this.get_options();
|
||||
|
||||
|
|
@ -89,25 +98,32 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
link_title = frappe.utils
|
||||
.fetch_link_title(doctype, value)
|
||||
.then(link_title => {
|
||||
this.set_input_value(link_title);
|
||||
this.title_value_map[link_title] = value;
|
||||
this.translate_and_set_input_value(link_title, value);
|
||||
});
|
||||
} else {
|
||||
this.set_input_value(link_title);
|
||||
this.title_value_map[link_title] = value;
|
||||
this.translate_and_set_input_value(link_title, value);
|
||||
}
|
||||
} else {
|
||||
this.set_input_value(value);
|
||||
this.translate_and_set_input_value(value, value)
|
||||
}
|
||||
}
|
||||
translate_and_set_input_value(link_title, value) {
|
||||
let translated_link_text = this.get_translated(link_title)
|
||||
this.title_value_map[translated_link_text] = value;
|
||||
|
||||
this.set_input_value(translated_link_text);
|
||||
}
|
||||
parse_validate_and_set_in_model(value, e, label) {
|
||||
if (this.parse) value = this.parse(value, label);
|
||||
if (label) {
|
||||
this.label = label;
|
||||
this.label = this.get_translated(label);
|
||||
frappe.utils.add_link_title(this.df.options, value, label);
|
||||
}
|
||||
|
||||
return this.validate_and_set_in_model(value, e);
|
||||
return this.validate_and_set_in_model(value, e, true);
|
||||
}
|
||||
parse(value) {
|
||||
return strip_html(value);
|
||||
}
|
||||
get_input_value() {
|
||||
if (this.$input) {
|
||||
|
|
@ -164,7 +180,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
return false;
|
||||
}
|
||||
setup_awesomeplete() {
|
||||
var me = this;
|
||||
let me = this;
|
||||
|
||||
this.$input.cache = {};
|
||||
|
||||
|
|
@ -173,14 +189,14 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
maxItems: 99,
|
||||
autoFirst: true,
|
||||
list: [],
|
||||
replace: function (suggestion) {
|
||||
replace: function (item) {
|
||||
// Override Awesomeplete replace function as it is used to set the input value
|
||||
// https://github.com/LeaVerou/awesomplete/issues/17104#issuecomment-359185403
|
||||
this.input.value = suggestion.label || suggestion.value;
|
||||
this.input.value = me.get_translated(item.label || item.value);
|
||||
},
|
||||
data: function (item) {
|
||||
return {
|
||||
label: item.label || item.value,
|
||||
label: me.get_translated(item.label || item.value),
|
||||
value: item.value
|
||||
};
|
||||
},
|
||||
|
|
@ -188,11 +204,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
return true;
|
||||
},
|
||||
item: function (item) {
|
||||
var d = this.get_item(item.value);
|
||||
let d = this.get_item(item.value);
|
||||
if(!d.label) { d.label = d.value; }
|
||||
|
||||
var _label = (me.translate_values) ? __(d.label) : d.label;
|
||||
var html = d.html || "<strong>" + _label + "</strong>";
|
||||
let _label = me.get_translated(d.label);
|
||||
let html = d.html || "<strong>" + _label + "</strong>";
|
||||
if(d.description && d.value!==d.description) {
|
||||
html += '<br><span class="small">' + __(d.description) + '</span>';
|
||||
}
|
||||
|
|
@ -296,18 +312,30 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
}
|
||||
let value = me.get_input_value();
|
||||
let label = me.get_label_value();
|
||||
let last_value = me.last_value || "";
|
||||
let last_label = me.label || "";
|
||||
|
||||
if (value !== me.last_value || me.label !== label) {
|
||||
if (value !== last_value || label !== last_label) {
|
||||
me.parse_validate_and_set_in_model(value, null, label);
|
||||
}
|
||||
});
|
||||
|
||||
this.$input.on("awesomplete-open", () => {
|
||||
this.autocomplete_open = true;
|
||||
|
||||
if (!me.get_label_value()) {
|
||||
// hide link arrow to doctype if none is set
|
||||
me.$link.toggle(false);
|
||||
}
|
||||
});
|
||||
|
||||
this.$input.on("awesomplete-close", () => {
|
||||
this.$input.on("awesomplete-close", (e) => {
|
||||
this.autocomplete_open = false;
|
||||
|
||||
if (!me.get_label_value()) {
|
||||
// hide link arrow to doctype if none is set
|
||||
me.$link.toggle(false);
|
||||
}
|
||||
});
|
||||
|
||||
this.$input.on("awesomplete-select", function(e) {
|
||||
|
|
@ -317,7 +345,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
me.autocomplete_open = false;
|
||||
|
||||
// prevent selection on tab
|
||||
var TABKEY = 9;
|
||||
let TABKEY = 9;
|
||||
if (e.keyCode === TABKEY) {
|
||||
e.preventDefault();
|
||||
me.awesomplete.close();
|
||||
|
|
@ -347,6 +375,24 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
me.$input.val("");
|
||||
}
|
||||
});
|
||||
|
||||
this.$input.on("focus", function () {
|
||||
if (!frappe.boot.translated_search_doctypes.includes(me.df.options)) {
|
||||
me.show_untranslated();
|
||||
}
|
||||
});
|
||||
|
||||
this.$input.keydown((e) => {
|
||||
let BACKSPACE = 8;
|
||||
if (e.keyCode === BACKSPACE && !frappe.boot.translated_search_doctypes.includes(me.df.options)) {
|
||||
me.show_untranslated();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
show_untranslated() {
|
||||
let value = this.get_input_value();
|
||||
this.is_translatable() && this.set_input_value(value);
|
||||
}
|
||||
|
||||
merge_duplicates(results) {
|
||||
|
|
@ -590,5 +636,4 @@ if (Awesomplete) {
|
|||
return item.value === value;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -73,7 +73,8 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro
|
|||
|
||||
if(this.$input) {
|
||||
var selected = this.$input.find(":selected").val();
|
||||
this.$input.empty().add_options(options || []);
|
||||
this.$input.empty();
|
||||
frappe.ui.form.add_options(this.$input, options || []);
|
||||
|
||||
if(value===undefined && selected) {
|
||||
this.$input.val(selected);
|
||||
|
|
@ -101,39 +102,47 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro
|
|||
}
|
||||
};
|
||||
|
||||
frappe.ui.form.add_options = function(input, options_list) {
|
||||
let $select = $(input);
|
||||
if (!Array.isArray(options_list)) {
|
||||
return $select;
|
||||
}
|
||||
// create options
|
||||
for(var i=0, j=options_list.length; i<j; i++) {
|
||||
var v = options_list[i];
|
||||
var value = null;
|
||||
var label = null;
|
||||
if (!is_null(v)) {
|
||||
var is_value_null = is_null(v.value);
|
||||
var is_label_null = is_null(v.label);
|
||||
var is_disabled = Boolean(v.disabled);
|
||||
var is_selected = Boolean(v.selected);
|
||||
|
||||
if (is_value_null && is_label_null) {
|
||||
value = v;
|
||||
label = __(v);
|
||||
} else {
|
||||
value = is_value_null ? "" : v.value;
|
||||
label = is_label_null ? __(value) : __(v.label);
|
||||
}
|
||||
}
|
||||
|
||||
$('<option>').html(cstr(label))
|
||||
.attr('value', value)
|
||||
.prop('disabled', is_disabled)
|
||||
.prop('selected', is_selected)
|
||||
.appendTo($select.get(0));
|
||||
}
|
||||
// select the first option
|
||||
$select.get(0).selectedIndex = 0;
|
||||
$select.trigger('select-change');
|
||||
return $select;
|
||||
};
|
||||
|
||||
// add <option> list to <select>
|
||||
(function($) {
|
||||
$.fn.add_options = function(options_list) {
|
||||
// create options
|
||||
for(var i=0, j=options_list.length; i<j; i++) {
|
||||
var v = options_list[i];
|
||||
var value = null;
|
||||
var label = null;
|
||||
if (!is_null(v)) {
|
||||
var is_value_null = is_null(v.value);
|
||||
var is_label_null = is_null(v.label);
|
||||
var is_disabled = Boolean(v.disabled);
|
||||
var is_selected = Boolean(v.selected);
|
||||
|
||||
if (is_value_null && is_label_null) {
|
||||
value = v;
|
||||
label = __(v);
|
||||
} else {
|
||||
value = is_value_null ? "" : v.value;
|
||||
label = is_label_null ? __(value) : __(v.label);
|
||||
}
|
||||
}
|
||||
|
||||
$('<option>').html(cstr(label))
|
||||
.attr('value', value)
|
||||
.prop('disabled', is_disabled)
|
||||
.prop('selected', is_selected)
|
||||
.appendTo(this);
|
||||
}
|
||||
// select the first option
|
||||
this.selectedIndex = 0;
|
||||
$(this).trigger('select-change');
|
||||
return $(this);
|
||||
return frappe.ui.form.add_options(this.get(0), options_list);
|
||||
};
|
||||
$.fn.set_working = function() {
|
||||
this.prop('disabled', true);
|
||||
|
|
|
|||
|
|
@ -161,4 +161,14 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f
|
|||
return true;
|
||||
};
|
||||
}
|
||||
get_input_value() {
|
||||
return this.$input ? this.$input.val() : undefined;
|
||||
}
|
||||
update_value() {
|
||||
let value = this.get_input_value();
|
||||
|
||||
if (value !== this.last_value) {
|
||||
this.parse_validate_and_set_in_model(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ const CodeBlockContainer = Quill.import('formats/code-block-container');
|
|||
CodeBlockContainer.tagName = 'PRE';
|
||||
Quill.register(CodeBlockContainer, true);
|
||||
|
||||
// font size
|
||||
let font_sizes = [false, '8px', '9px', '10px', '11px', '12px', '13px', '14px', '15px', '16px', '18px', '20px', '22px', '24px', '32px', '36px', '40px', '48px', '54px', '64px', '96px', '128px'];
|
||||
const Size = Quill.import('attributors/style/size');
|
||||
Size.whitelist = font_sizes;
|
||||
Quill.register(Size, true);
|
||||
|
||||
// table
|
||||
const Table = Quill.import('formats/table-container');
|
||||
const superCreate = Table.create.bind(Table);
|
||||
|
|
@ -145,6 +151,15 @@ frappe.ui.form.ControlTextEditor = class ControlTextEditor extends frappe.ui.for
|
|||
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// font size dropdown
|
||||
let $font_size_label = this.$wrapper.find('.ql-size .ql-picker-label:first');
|
||||
let $default_font_size = this.$wrapper.find('.ql-size .ql-picker-item:first');
|
||||
|
||||
if ($font_size_label.length) {
|
||||
$font_size_label.attr('data-value', '---');
|
||||
$default_font_size.attr('data-value', '---');
|
||||
}
|
||||
}
|
||||
|
||||
is_quill_dirty(source) {
|
||||
|
|
@ -167,7 +182,8 @@ frappe.ui.form.ControlTextEditor = class ControlTextEditor extends frappe.ui.for
|
|||
|
||||
get_toolbar_options() {
|
||||
return [
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
[{ size: font_sizes }],
|
||||
['bold', 'italic', 'underline', 'clean'],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
['blockquote', 'code-block'],
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export default class ListSettings {
|
|||
if (field_count < 4) {
|
||||
field_count = 4;
|
||||
} else if (field_count > 10) {
|
||||
field_count = 4;
|
||||
field_count = 10;
|
||||
}
|
||||
|
||||
me.dialog.set_value("total_fields", field_count);
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
if (window.innerWidth <= 1366) {
|
||||
total_fields = 4;
|
||||
} else if (window.innerWidth >= 1920) {
|
||||
total_fields = 8;
|
||||
total_fields = 10;
|
||||
}
|
||||
|
||||
this.columns = this.columns.slice(0, this.list_view_settings.total_fields || total_fields);
|
||||
|
|
@ -1973,22 +1973,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
|
||||
return filters;
|
||||
}
|
||||
|
||||
static trigger_list_update(data) {
|
||||
const doctype = data.doctype;
|
||||
if (!doctype) return;
|
||||
frappe.provide("frappe.views.trees");
|
||||
|
||||
// refresh list view
|
||||
const page_name = frappe.get_route_str();
|
||||
const list_view = frappe.views.list_view[page_name];
|
||||
list_view && list_view.on_update(data);
|
||||
}
|
||||
};
|
||||
|
||||
$(document).on("save", (event, doc) => {
|
||||
frappe.views.ListView.trigger_list_update(doc);
|
||||
});
|
||||
|
||||
frappe.get_list_view = (doctype) => {
|
||||
let route = `List/${doctype}/List`;
|
||||
|
|
|
|||
|
|
@ -47,8 +47,6 @@ $.extend(frappe.model, {
|
|||
init: function() {
|
||||
// setup refresh if the document is updated somewhere else
|
||||
frappe.realtime.on("doc_update", function(data) {
|
||||
// set list dirty
|
||||
frappe.views.ListView.trigger_list_update(data);
|
||||
var doc = locals[data.doctype] && locals[data.doctype][data.name];
|
||||
|
||||
if(doc) {
|
||||
|
|
@ -69,11 +67,6 @@ $.extend(frappe.model, {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
frappe.realtime.on("list_update", function(data) {
|
||||
frappe.views.ListView.trigger_list_update(data);
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
is_value_type: function(fieldtype) {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
|
|||
|
||||
if (!is_null(v)) ret[f.df.fieldname] = v;
|
||||
}
|
||||
|
||||
if (this.is_dialog && f.df.reqd && !f.value) {
|
||||
f.refresh_input();
|
||||
}
|
||||
}
|
||||
if (errors.length && !ignore_errors) {
|
||||
frappe.msgprint({
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ frappe.ui.FilterGroup = class {
|
|||
}
|
||||
|
||||
add_filters_to_filter_group(filters) {
|
||||
if (filters.length) {
|
||||
if (filters && filters.length) {
|
||||
this.toggle_empty_filters(false);
|
||||
filters.forEach((filter) => {
|
||||
this.add_filter(filter[0], filter[1], filter[2], filter[3]);
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ $.extend(frappe.datetime, {
|
|||
return frappe.sys_defaults && frappe.sys_defaults.date_format || "yyyy-mm-dd";
|
||||
},
|
||||
|
||||
str_to_user: function(val, only_time = false) {
|
||||
str_to_user: function(val, only_time=false, only_date=false) {
|
||||
if (!val) return "";
|
||||
const user_date_fmt = frappe.datetime.get_user_date_fmt().toUpperCase();
|
||||
const user_time_fmt = frappe.datetime.get_user_time_fmt();
|
||||
|
|
@ -142,6 +142,9 @@ $.extend(frappe.datetime, {
|
|||
if (only_time) {
|
||||
let date_obj = moment(val, frappe.defaultTimeFormat);
|
||||
return date_obj.format(user_format);
|
||||
} else if (only_date) {
|
||||
let date_obj = moment(val, frappe.defaultDateFormat);
|
||||
return date_obj.format(user_date_fmt);
|
||||
} else {
|
||||
let date_obj = moment.tz(val, frappe.boot.time_zone.system);
|
||||
if (typeof val !== "string" || val.indexOf(" ") === -1) {
|
||||
|
|
|
|||
|
|
@ -1486,5 +1486,47 @@ Object.assign(frappe.utils, {
|
|||
case "f": case "false": case "n": case "no": case "0": case null: return false;
|
||||
default: return string;
|
||||
}
|
||||
},
|
||||
|
||||
get_filter_as_json(filters) {
|
||||
// convert filter array to json
|
||||
let filter = null;
|
||||
|
||||
if (filters.length) {
|
||||
filter = {};
|
||||
filters.forEach(arr => {
|
||||
filter[arr[1]] = [arr[2], arr[3]];
|
||||
});
|
||||
filter = JSON.stringify(filter);
|
||||
}
|
||||
|
||||
return filter;
|
||||
},
|
||||
|
||||
get_filter_from_json(filter_json, doctype) {
|
||||
// convert json to filter array
|
||||
if (filter_json) {
|
||||
if (!filter_json.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filters_json = new Function(`return ${filter_json}`)();
|
||||
if (!doctype) {
|
||||
// e.g. return {
|
||||
// priority: (2) ['=', 'Medium'],
|
||||
// status: (2) ['=', 'Open']
|
||||
// }
|
||||
return filters_json || [];
|
||||
}
|
||||
|
||||
// e.g. return [
|
||||
// ['ToDo', 'status', '=', 'Open', false],
|
||||
// ['ToDo', 'priority', '=', 'Medium', false]
|
||||
// ]
|
||||
return Object.keys(filters_json).map(filter => {
|
||||
let val = filters_json[filter];
|
||||
return [doctype, filter, val[0], val[1], false];
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -766,17 +766,23 @@ frappe.views.CommunicationComposer = class {
|
|||
filters['default_outgoing'] = 1;
|
||||
}
|
||||
|
||||
const email = await frappe.db.get_list("Email Account", {
|
||||
const email_accounts = await frappe.db.get_list("Email Account", {
|
||||
filters: filters,
|
||||
fields: ['signature', 'email_id'],
|
||||
limit: 1
|
||||
});
|
||||
|
||||
signature = email && email[0].signature;
|
||||
let filtered_email = null;
|
||||
if (email_accounts.length) {
|
||||
signature = email_accounts[0].signature;
|
||||
filtered_email = email_accounts[0].email_id;
|
||||
}
|
||||
|
||||
if (this.user_email_accounts &&
|
||||
this.user_email_accounts.includes(email[0].email_id)) {
|
||||
this.dialog.set_value('sender', email[0].email_id);
|
||||
if (!sender_email && filtered_email) {
|
||||
if (this.user_email_accounts &&
|
||||
this.user_email_accounts.includes(filtered_email)) {
|
||||
this.dialog.set_value('sender', filtered_email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,9 +48,14 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
|
|||
setup_view() {
|
||||
this.setup_columns();
|
||||
super.setup_new_doc_event();
|
||||
this.setup_events()
|
||||
this.page.main.addClass('report-view');
|
||||
}
|
||||
|
||||
setup_events() {
|
||||
frappe.realtime.on("list_update", (data) => this.on_update(data));
|
||||
}
|
||||
|
||||
setup_page() {
|
||||
this.menu_items = this.report_menu_items();
|
||||
super.setup_page();
|
||||
|
|
@ -804,7 +809,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
|
|||
|
||||
add_status_dependency_column(col, doctype) {
|
||||
// Adds dependent column from which status is derived if required
|
||||
if (!this.fields.find(f => f[0] === col)) {
|
||||
if (col && !this.fields.find(f => f[0] === col)) {
|
||||
const field = [col, doctype];
|
||||
this.fields.push(field);
|
||||
this.refresh();
|
||||
|
|
@ -1152,12 +1157,10 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
|
|||
// get status from docstatus
|
||||
let status = frappe.get_indicator(d, this.doctype);
|
||||
if (status) {
|
||||
if (!status[0]) {
|
||||
// get_indicator returns the dependent field's condition as the 3rd parameter
|
||||
let dependent_col = status[2].split(',')[0];
|
||||
// add status dependency column
|
||||
this.add_status_dependency_column(dependent_col, this.doctype);
|
||||
}
|
||||
// get_indicator returns the dependent field's condition as the 3rd parameter
|
||||
let dependent_col = status[2]?.split(',')[0];
|
||||
// add status dependency column
|
||||
this.add_status_dependency_column(dependent_col, this.doctype);
|
||||
return {
|
||||
name: d.name,
|
||||
doctype: col.docfield.parent,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Chart from "./chart";
|
|||
import Shortcut from "./shortcut";
|
||||
import Spacer from "./spacer";
|
||||
import Onboarding from "./onboarding";
|
||||
import QuickList from "./quick_list";
|
||||
|
||||
// import tunes
|
||||
import HeaderSize from "./header_size";
|
||||
|
|
@ -20,6 +21,7 @@ frappe.workspace_block.blocks = {
|
|||
shortcut: Shortcut,
|
||||
spacer: Spacer,
|
||||
onboarding: Onboarding,
|
||||
quick_list: QuickList,
|
||||
};
|
||||
|
||||
frappe.workspace_block.tunes = {
|
||||
|
|
|
|||
63
frappe/public/js/frappe/views/workspace/blocks/quick_list.js
Normal file
63
frappe/public/js/frappe/views/workspace/blocks/quick_list.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import Block from "./block.js";
|
||||
export default class QuickList extends Block {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Quick List',
|
||||
icon: frappe.utils.icon('list', 'sm')
|
||||
};
|
||||
}
|
||||
|
||||
static get isReadOnlySupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
constructor({ data, api, config, readOnly, block }) {
|
||||
super({ data, api, config, readOnly, block });
|
||||
this.col = this.data.col ? this.data.col : "4";
|
||||
this.allow_customization = !this.readOnly;
|
||||
this.options = {
|
||||
allow_sorting: this.allow_customization,
|
||||
allow_create: this.allow_customization,
|
||||
allow_delete: this.allow_customization,
|
||||
allow_hiding: false,
|
||||
allow_edit: true,
|
||||
allow_resize: true,
|
||||
min_width: 4,
|
||||
max_widget_count: 2
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.new('quick_list');
|
||||
|
||||
if (this.data && this.data.quick_list_name) {
|
||||
let has_data = this.make('quick_list', this.data.quick_list_name);
|
||||
if (!has_data) return;
|
||||
}
|
||||
|
||||
if (!this.readOnly) {
|
||||
$(this.wrapper).find('.widget').addClass('quick_list edit-mode');
|
||||
this.add_settings_button();
|
||||
this.add_new_block_button();
|
||||
}
|
||||
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
validate(savedData) {
|
||||
if (!savedData.quick_list_name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
save() {
|
||||
return {
|
||||
quick_list_name: this.wrapper.getAttribute('quick_list_name'),
|
||||
col: this.get_col(),
|
||||
new: this.new_block_widget
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -325,6 +325,7 @@ frappe.views.Workspace = class Workspace {
|
|||
this.editor.configuration.tools.shortcut.config.page_data = this.page_data;
|
||||
this.editor.configuration.tools.card.config.page_data = this.page_data;
|
||||
this.editor.configuration.tools.onboarding.config.page_data = this.page_data;
|
||||
this.editor.configuration.tools.quick_list.config.page_data = this.page_data;
|
||||
this.editor.render({ blocks: this.content || [] });
|
||||
});
|
||||
} else {
|
||||
|
|
@ -1121,6 +1122,12 @@ frappe.views.Workspace = class Workspace {
|
|||
page_data: this.page_data || []
|
||||
}
|
||||
},
|
||||
quick_list: {
|
||||
class: this.blocks['quick_list'],
|
||||
config: {
|
||||
page_data: this.page_data || []
|
||||
}
|
||||
},
|
||||
spacer: this.blocks['spacer'],
|
||||
HeaderSize: frappe.workspace_block.tunes['header_size'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ frappe.ready(function() {
|
|||
window.location.replace('/login?redirect-to=' + window.location.pathname);
|
||||
}
|
||||
});
|
||||
login_required.set_message(__("You are not permitted to access this page."));
|
||||
login_required.show();
|
||||
login_required.set_message(__("You are not permitted to access this page without login."));
|
||||
}
|
||||
|
||||
function show_grid() {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export default class Widget {
|
|||
this.set_actions();
|
||||
this.set_body();
|
||||
this.setup_events();
|
||||
this.set_footer();
|
||||
}
|
||||
|
||||
get_config() {
|
||||
|
|
@ -196,4 +197,8 @@ export default class Widget {
|
|||
set_body() {
|
||||
//
|
||||
}
|
||||
|
||||
set_footer() {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export default class ChartWidget extends Widget {
|
|||
|
||||
this.empty = $(
|
||||
`<div class="chart-loading-state text-muted" style="height: ${this.height}px;">${__(
|
||||
"No Data..."
|
||||
"No Data"
|
||||
)}</div>`
|
||||
);
|
||||
this.empty.hide().appendTo(this.body);
|
||||
|
|
@ -340,7 +340,8 @@ export default class ChartWidget extends Widget {
|
|||
handler: () => {
|
||||
frappe.set_route(
|
||||
"query-report",
|
||||
this.chart_doc.report_name
|
||||
this.chart_doc.report_name,
|
||||
this.filters
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -529,7 +530,26 @@ export default class ChartWidget extends Widget {
|
|||
return frappe.xcall(method, args);
|
||||
}
|
||||
|
||||
render() {
|
||||
async get_source_doctype() {
|
||||
if (this.chart_doc.document_type) {
|
||||
return this.chart_doc.document_type;
|
||||
}
|
||||
if (this.chart_doc.chart_type == "Report" && this.chart_doc.report_name) {
|
||||
return await frappe.db.get_value("Report", this.chart_doc.report_name, "ref_doctype").then(r => r.message.ref_doctype);
|
||||
}
|
||||
}
|
||||
|
||||
async render() {
|
||||
let setup_dashboard_chart = () => {
|
||||
const chart_args = this.get_chart_args();
|
||||
|
||||
if (!this.dashboard_chart) {
|
||||
this.dashboard_chart = frappe.utils.make_chart(this.chart_wrapper[0], chart_args);
|
||||
} else {
|
||||
this.dashboard_chart.update(this.data);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.data || !this.data.labels || !Object.keys(this.data).length) {
|
||||
this.chart_wrapper.hide();
|
||||
this.loading.hide();
|
||||
|
|
@ -539,13 +559,12 @@ export default class ChartWidget extends Widget {
|
|||
this.loading.hide();
|
||||
this.empty.hide();
|
||||
this.chart_wrapper.show();
|
||||
this.chart_doc.document_type = await this.get_source_doctype();
|
||||
|
||||
const chart_args = this.get_chart_args();
|
||||
|
||||
if (!this.dashboard_chart) {
|
||||
this.dashboard_chart = frappe.utils.make_chart(this.chart_wrapper[0], chart_args);
|
||||
if (this.chart_doc.document_type) {
|
||||
frappe.model.with_doctype(this.chart_doc.document_type, setup_dashboard_chart);
|
||||
} else {
|
||||
this.dashboard_chart.update(this.data);
|
||||
setup_dashboard_chart();
|
||||
}
|
||||
|
||||
this.width == "Full" && this.summary && this.set_summary();
|
||||
|
|
@ -555,6 +574,7 @@ export default class ChartWidget extends Widget {
|
|||
|
||||
get_chart_args() {
|
||||
let colors = this.get_chart_colors();
|
||||
let fieldtype, options;
|
||||
|
||||
const chart_type_map = {
|
||||
Line: "line",
|
||||
|
|
@ -577,16 +597,22 @@ export default class ChartWidget extends Widget {
|
|||
},
|
||||
};
|
||||
|
||||
if (this.report_result && this.report_result.chart) {
|
||||
chart_args.tooltipOptions = {
|
||||
formatTooltipY: value =>
|
||||
frappe.format(value, {
|
||||
fieldtype: this.report_result.chart.fieldtype,
|
||||
options: this.report_result.chart.options
|
||||
}, { always_show_decimals: true, inline: true })
|
||||
};
|
||||
if (this.chart_doc.document_type) {
|
||||
let doctype_meta = frappe.get_meta(this.chart_doc.document_type);
|
||||
let field = doctype_meta.fields.find(x => x.fieldname == this.chart_doc.value_based_on);
|
||||
fieldtype = field.fieldtype;
|
||||
options = field.options;
|
||||
}
|
||||
|
||||
if (this.chart_doc.chart_type == "Report" && this.report_result?.chart?.fieldtype) {
|
||||
fieldtype = this.report_result.chart.fieldtype;
|
||||
options = this.report_result.chart.options;
|
||||
}
|
||||
|
||||
chart_args.tooltipOptions = {
|
||||
formatTooltipY: value => frappe.format(value, { fieldtype, options }, { always_show_decimals: true, inline: true })
|
||||
};
|
||||
|
||||
if (this.chart_doc.type == "Heatmap") {
|
||||
const heatmap_year = parseInt(this.selected_heatmap_year || this.chart_settings.heatmap_year || this.chart_doc.heatmap_year);
|
||||
chart_args.data.start = new Date(`${heatmap_year}-01-01`);
|
||||
|
|
|
|||
|
|
@ -255,18 +255,21 @@ export default class NumberCardWidget extends Widget {
|
|||
};
|
||||
const stats_qualifier = stats_qualifier_map[this.card_doc.stats_time_interval];
|
||||
|
||||
let get_stat = () => {
|
||||
let stat = (() => {
|
||||
if (this.percentage_stat == undefined) return NaN;
|
||||
const parts = this.percentage_stat.split(' ');
|
||||
const symbol = parts[1] || '';
|
||||
return Math.abs(parts[0]) + ' ' + symbol;
|
||||
};
|
||||
})();
|
||||
|
||||
// don't show stats if not valid number - skip showing `NaN %` in card
|
||||
if (isNaN(stat)) return;
|
||||
|
||||
$(this.body).find('.widget-content').append(`<div class="card-stats ${color_class}">
|
||||
<span class="percentage-stat-area">
|
||||
${caret_html}
|
||||
<span class="percentage-stat">
|
||||
${get_stat()} %
|
||||
${stat} %
|
||||
</span>
|
||||
</span>
|
||||
<span class="stat-period text-muted">
|
||||
|
|
|
|||
247
frappe/public/js/frappe/widgets/quick_list_widget.js
Normal file
247
frappe/public/js/frappe/widgets/quick_list_widget.js
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import Widget from "./base_widget.js";
|
||||
|
||||
frappe.provide("frappe.utils");
|
||||
|
||||
export default class QuickListWidget extends Widget {
|
||||
constructor(opts) {
|
||||
opts.shadow = true;
|
||||
super(opts);
|
||||
}
|
||||
|
||||
get_config() {
|
||||
return {
|
||||
document_type: this.document_type,
|
||||
label: this.label,
|
||||
quick_list_filter: this.quick_list_filter
|
||||
};
|
||||
}
|
||||
|
||||
set_actions() {
|
||||
if (this.in_customize_mode) return;
|
||||
|
||||
this.setup_add_new_button();
|
||||
this.setup_refresh_list_button();
|
||||
this.setup_filter_list_button();
|
||||
}
|
||||
|
||||
setup_add_new_button() {
|
||||
this.add_new_button = $(
|
||||
`<div class="add-new btn btn-xs pull-right" title="${__("Add New " + this.document_type)}">
|
||||
${frappe.utils.icon('add', 'sm')}
|
||||
</div>`
|
||||
);
|
||||
|
||||
this.add_new_button.appendTo(this.action_area);
|
||||
this.add_new_button.on("click", () => {
|
||||
frappe.set_route(
|
||||
frappe.utils.generate_route({type: 'doctype', name: this.document_type, doc_view: 'New'})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
setup_refresh_list_button() {
|
||||
this.refresh_list = $(
|
||||
`<div class="refresh-list btn btn-xs pull-right" title="${__("Refresh List")}">
|
||||
${frappe.utils.icon('refresh', 'sm')}
|
||||
</div>`
|
||||
);
|
||||
|
||||
this.refresh_list.appendTo(this.action_area);
|
||||
this.refresh_list.on("click", () => {
|
||||
this.body.empty();
|
||||
this.set_body();
|
||||
});
|
||||
}
|
||||
|
||||
setup_filter_list_button() {
|
||||
this.filter_list = $(
|
||||
`<div class="filter-list btn btn-xs pull-right" title="${__("Add/Update Filter")}">
|
||||
${frappe.utils.icon('filter', 'sm')}
|
||||
</div>`
|
||||
);
|
||||
|
||||
this.filter_list.appendTo(this.action_area);
|
||||
this.filter_list.on("click", () => this.setup_filter_dialog());
|
||||
}
|
||||
|
||||
setup_filter(doctype) {
|
||||
if (this.filter_group) {
|
||||
this.filter_group.wrapper.empty();
|
||||
delete this.filter_group;
|
||||
}
|
||||
|
||||
this.filters = frappe.utils.get_filter_from_json(this.quick_list_filter, doctype);
|
||||
|
||||
this.filter_group = new frappe.ui.FilterGroup({
|
||||
parent: this.dialog.get_field("filter_area").$wrapper,
|
||||
doctype: doctype,
|
||||
on_change: () => {},
|
||||
});
|
||||
|
||||
frappe.model.with_doctype(doctype, () => {
|
||||
this.filter_group.add_filters_to_filter_group(this.filters);
|
||||
this.dialog.set_df_property("filter_area", "hidden", false);
|
||||
});
|
||||
}
|
||||
|
||||
setup_filter_dialog() {
|
||||
let fields = [
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
fieldname: "filter_area"
|
||||
}
|
||||
];
|
||||
let me = this;
|
||||
this.dialog = new frappe.ui.Dialog({
|
||||
title: __("Set Filters for {0}", [this.document_type]),
|
||||
fields: fields,
|
||||
primary_action: function() {
|
||||
let old_filter = me.quick_list_filter;
|
||||
let filters = me.filter_group.get_filters();
|
||||
me.quick_list_filter = frappe.utils.get_filter_as_json(filters);
|
||||
|
||||
this.hide();
|
||||
|
||||
if (old_filter != me.quick_list_filter) {
|
||||
me.body.empty();
|
||||
me.set_body();
|
||||
}
|
||||
},
|
||||
primary_action_label: "Set"
|
||||
});
|
||||
|
||||
this.dialog.show();
|
||||
this.setup_filter(this.document_type);
|
||||
}
|
||||
|
||||
render_loading_state() {
|
||||
this.body.empty();
|
||||
this.loading = $(
|
||||
`<div class="list-loading-state text-muted">${__(
|
||||
"Loading..."
|
||||
)}</div>`
|
||||
);
|
||||
this.loading.appendTo(this.body);
|
||||
}
|
||||
|
||||
render_no_data_state() {
|
||||
this.loading = $(
|
||||
`<div class="list-no-data-state text-muted">${__(
|
||||
"No Data..."
|
||||
)}</div>`
|
||||
);
|
||||
this.loading.appendTo(this.body);
|
||||
}
|
||||
|
||||
setup_quick_list_item(doc) {
|
||||
const indicator = frappe.get_indicator(doc, this.document_type);
|
||||
|
||||
let $quick_list_item = $(`
|
||||
<div class="quick-list-item">
|
||||
<div class="ellipsis left">
|
||||
<div class="ellipsis title"
|
||||
title="${strip_html(doc[this.title_field_name])}">
|
||||
${strip_html(doc[this.title_field_name])}
|
||||
</div>
|
||||
<div class="timestamp text-muted">
|
||||
${frappe.datetime.prettyDate(doc.modified)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
if (indicator) {
|
||||
$(`
|
||||
<div class="status indicator-pill ${indicator[1]} ellipsis">
|
||||
${__(indicator[0])}
|
||||
</div>
|
||||
`).appendTo($quick_list_item);
|
||||
}
|
||||
|
||||
$(`<div class="right-arrow">${frappe.utils.icon('right', 'xs')}</div>`).appendTo($quick_list_item);
|
||||
|
||||
$quick_list_item.click(() => {
|
||||
frappe.set_route(`${frappe.utils.get_form_link(this.document_type, doc.name)}`);
|
||||
});
|
||||
|
||||
return $quick_list_item;
|
||||
}
|
||||
|
||||
set_body() {
|
||||
this.widget.addClass("quick-list-widget-box");
|
||||
|
||||
this.render_loading_state();
|
||||
|
||||
frappe.model.with_doctype(this.document_type, () => {
|
||||
let fields = ['name'];
|
||||
|
||||
// get name of title field
|
||||
if (!this.title_field_name) {
|
||||
let meta = frappe.get_meta(this.document_type);
|
||||
this.title_field_name = meta && meta.title_field || 'name';
|
||||
}
|
||||
|
||||
if (this.title_field_name && this.title_field_name != 'name') {
|
||||
fields.push(this.title_field_name);
|
||||
}
|
||||
|
||||
// check doctype has status field
|
||||
this.has_status_field = frappe.meta.has_field(this.document_type, 'status');
|
||||
|
||||
if (this.has_status_field) {
|
||||
fields.push('status');
|
||||
fields.push('docstatus');
|
||||
}
|
||||
|
||||
fields.push('modified');
|
||||
|
||||
let quick_list_filter = frappe.utils.get_filter_from_json(this.quick_list_filter);
|
||||
|
||||
let args = {
|
||||
method: 'frappe.desk.reportview.get',
|
||||
args: {
|
||||
doctype: this.document_type,
|
||||
fields: fields,
|
||||
filters: quick_list_filter,
|
||||
order_by: 'modified desc',
|
||||
start: 0,
|
||||
page_length: 4
|
||||
}
|
||||
};
|
||||
|
||||
frappe.call(args).then((r) => {
|
||||
if (!r.message) return;
|
||||
let data = r.message;
|
||||
|
||||
this.body.empty();
|
||||
data = !Array.isArray(data)
|
||||
? frappe.utils.dict(data.keys, data.values)
|
||||
: data;
|
||||
|
||||
if (!data.length) {
|
||||
this.render_no_data_state();
|
||||
return;
|
||||
}
|
||||
|
||||
this.quick_list = data.map(doc => this.setup_quick_list_item(doc));
|
||||
this.quick_list.forEach($quick_list_item => $quick_list_item.appendTo(this.body));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
set_footer() {
|
||||
if (!this.see_all_button) {
|
||||
this.see_all_button = $(`
|
||||
<div class="see-all btn">See all</div>
|
||||
`).appendTo(this.footer);
|
||||
|
||||
this.see_all_button.click(() => {
|
||||
let filters = frappe.utils.get_filter_from_json(this.quick_list_filter);
|
||||
if (filters) {
|
||||
frappe.route_options = filters;
|
||||
}
|
||||
frappe.set_route(frappe.utils.generate_route({type: 'doctype', name: this.document_type}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ export default class ShortcutWidget extends Widget {
|
|||
doc_view: this.doc_view
|
||||
});
|
||||
|
||||
let filters = this.get_doctype_filter();
|
||||
let filters = frappe.utils.get_filter_from_json(this.stats_filter);
|
||||
if (this.type == "DocType" && filters) {
|
||||
frappe.route_options = filters;
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export default class ShortcutWidget extends Widget {
|
|||
|
||||
this.widget.addClass("shortcut-widget-box");
|
||||
|
||||
let filters = this.get_doctype_filter();
|
||||
let filters = frappe.utils.get_filter_from_json(this.stats_filter);
|
||||
if (this.type == "DocType" && filters) {
|
||||
frappe.db
|
||||
.count(this.link_to, {
|
||||
|
|
@ -59,15 +59,6 @@ export default class ShortcutWidget extends Widget {
|
|||
}
|
||||
}
|
||||
|
||||
get_doctype_filter() {
|
||||
let count_filter = new Function(`return ${this.stats_filter}`)();
|
||||
if (count_filter) {
|
||||
return count_filter;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
set_count(count) {
|
||||
const get_label = () => {
|
||||
if (this.format) {
|
||||
|
|
|
|||
|
|
@ -75,13 +75,7 @@ class WidgetDialog {
|
|||
|
||||
this.filters = [];
|
||||
|
||||
if (this.values && this.values.stats_filter) {
|
||||
const filters_json = new Function(`return ${this.values.stats_filter}`)();
|
||||
this.filters = Object.keys(filters_json).map((filter) => {
|
||||
let val = filters_json[filter];
|
||||
return [this.values.link_to, filter, val[0], val[1], false];
|
||||
});
|
||||
}
|
||||
this.generate_filter_from_json();
|
||||
|
||||
this.filter_group = new frappe.ui.FilterGroup({
|
||||
parent: this.dialog.get_field("filter_area").$wrapper,
|
||||
|
|
@ -124,6 +118,74 @@ class ChartDialog extends WidgetDialog {
|
|||
return data;
|
||||
}
|
||||
}
|
||||
class QuickListDialog extends WidgetDialog {
|
||||
constructor(opts) {
|
||||
super(opts);
|
||||
}
|
||||
|
||||
get_fields() {
|
||||
return [
|
||||
{
|
||||
fieldtype: "Link",
|
||||
fieldname: "document_type",
|
||||
label: "DocType",
|
||||
options: "DocType",
|
||||
reqd: 1,
|
||||
onchange: () => {
|
||||
this.document_type = this.dialog.get_value("document_type");
|
||||
this.document_type && this.setup_filter(this.document_type);
|
||||
},
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: {
|
||||
issingle: 0,
|
||||
istable: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldtype: "Column Break",
|
||||
fieldname: "column_break_4",
|
||||
},
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "label",
|
||||
label: "Label",
|
||||
},
|
||||
{
|
||||
fieldtype: "Section Break",
|
||||
fieldname: "filter_section",
|
||||
label: __('Add Filters'),
|
||||
depends_on: 'eval: doc.document_type'
|
||||
},
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
fieldname: "filter_area_loading",
|
||||
},
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
fieldname: "filter_area"
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
generate_filter_from_json() {
|
||||
if (this.values && this.values.quick_list_filter) {
|
||||
this.filters = frappe.utils.get_filter_from_json(this.values.quick_list_filter, this.values.document_type);
|
||||
}
|
||||
}
|
||||
|
||||
process_data(data) {
|
||||
if (this.filter_group) {
|
||||
let filters = this.filter_group.get_filters();
|
||||
data.quick_list_filter = frappe.utils.get_filter_as_json(filters);
|
||||
}
|
||||
|
||||
data.label = data.label ? data.label : data.document_type;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
class OnboardingDialog extends WidgetDialog {
|
||||
constructor(opts) {
|
||||
|
|
@ -410,20 +472,17 @@ class ShortcutDialog extends WidgetDialog {
|
|||
});
|
||||
}
|
||||
|
||||
generate_filter_from_json() {
|
||||
if (this.values && this.values.stats_filter) {
|
||||
this.filters = frappe.utils.get_filter_from_json(this.values.stats_filter, this.values.link_to);
|
||||
}
|
||||
}
|
||||
|
||||
process_data(data) {
|
||||
|
||||
if (this.dialog.get_value("type") == "DocType" && this.filter_group) {
|
||||
let filters = this.filter_group.get_filters();
|
||||
let stats_filter = null;
|
||||
|
||||
if (filters.length) {
|
||||
stats_filter = {};
|
||||
filters.forEach((arr) => {
|
||||
stats_filter[arr[1]] = [arr[2], arr[3]];
|
||||
});
|
||||
stats_filter = JSON.stringify(stats_filter);
|
||||
}
|
||||
data.stats_filter = stats_filter;
|
||||
data.stats_filter = frappe.utils.get_filter_as_json(filters);
|
||||
}
|
||||
|
||||
data.label = data.label
|
||||
|
|
@ -579,7 +638,8 @@ export default function get_dialog_constructor(type) {
|
|||
shortcut: ShortcutDialog,
|
||||
number_card: NumberCardDialog,
|
||||
links: CardDialog,
|
||||
onboarding: OnboardingDialog
|
||||
onboarding: OnboardingDialog,
|
||||
quick_list: QuickListDialog
|
||||
};
|
||||
|
||||
return widget_map[type] || WidgetDialog;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import LinksWidget from "../widgets/links_widget";
|
|||
import OnboardingWidget from "../widgets/onboarding_widget";
|
||||
import NewWidget from "../widgets/new_widget";
|
||||
import NumberCardWidget from "../widgets/number_card_widget";
|
||||
import QuickListWidget from "../widgets/quick_list_widget";
|
||||
|
||||
frappe.provide("frappe.widget");
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ frappe.widget.widget_factory = {
|
|||
links: LinksWidget,
|
||||
onboarding: OnboardingWidget,
|
||||
number_card: NumberCardWidget,
|
||||
quick_list: QuickListWidget
|
||||
};
|
||||
|
||||
frappe.widget.make_widget = (opts) => {
|
||||
|
|
|
|||
16
frappe/public/js/lib/clusterize.min.js
vendored
16
frappe/public/js/lib/clusterize.min.js
vendored
|
|
@ -1,16 +0,0 @@
|
|||
/*! Clusterize.js - v0.17.6 - 2017-03-05
|
||||
* http://NeXTs.github.com/Clusterize.js/
|
||||
* Copyright (c) 2015 Denis Lukov; Licensed GPLv3 */
|
||||
|
||||
;(function(q,n){"undefined"!=typeof module?module.exports=n():"function"==typeof define&&"object"==typeof define.amd?define(n):this[q]=n()})("Clusterize",function(){function q(b,a,c){return a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent("on"+b,c)}function n(b,a,c){return a.removeEventListener?a.removeEventListener(b,c,!1):a.detachEvent("on"+b,c)}function r(b){return"[object Array]"===Object.prototype.toString.call(b)}function m(b,a){return window.getComputedStyle?window.getComputedStyle(a)[b]:
|
||||
a.currentStyle[b]}var l=function(){for(var b=3,a=document.createElement("b"),c=a.all||[];a.innerHTML="\x3c!--[if gt IE "+ ++b+"]><i><![endif]--\x3e",c[0];);return 4<b?b:document.documentMode}(),x=navigator.platform.toLowerCase().indexOf("mac")+1,p=function(b){if(!(this instanceof p))return new p(b);var a=this,c={rows_in_block:50,blocks_in_cluster:4,tag:null,show_no_data_row:!0,no_data_class:"clusterize-no-data",no_data_text:"No data",keep_parity:!0,callbacks:{}};a.options={};for(var d="rows_in_block blocks_in_cluster show_no_data_row no_data_class no_data_text keep_parity tag callbacks".split(" "),
|
||||
f=0,h;h=d[f];f++)a.options[h]="undefined"!=typeof b[h]&&null!=b[h]?b[h]:c[h];c=["scroll","content"];for(f=0;d=c[f];f++)if(a[d+"_elem"]=b[d+"Id"]?document.getElementById(b[d+"Id"]):b[d+"Elem"],!a[d+"_elem"])throw Error("Error! Could not find "+d+" element");a.content_elem.hasAttribute("tabindex")||a.content_elem.setAttribute("tabindex",0);var e=r(b.rows)?b.rows:a.fetchMarkup(),g={};b=a.scroll_elem.scrollTop;a.insertToDOM(e,g);a.scroll_elem.scrollTop=b;var k=!1,m=0,l=!1,t=function(){x&&(l||(a.content_elem.style.pointerEvents=
|
||||
"none"),l=!0,clearTimeout(m),m=setTimeout(function(){a.content_elem.style.pointerEvents="auto";l=!1},50));k!=(k=a.getClusterNum())&&a.insertToDOM(e,g);a.options.callbacks.scrollingProgress&&a.options.callbacks.scrollingProgress(a.getScrollProgress())},u=0,v=function(){clearTimeout(u);u=setTimeout(a.refresh,100)};q("scroll",a.scroll_elem,t);q("resize",window,v);a.destroy=function(b){n("scroll",a.scroll_elem,t);n("resize",window,v);a.html((b?a.generateEmptyRow():e).join(""))};a.refresh=function(b){(a.getRowsHeight(e)||
|
||||
b)&&a.update(e)};a.update=function(b){e=r(b)?b:[];b=a.scroll_elem.scrollTop;e.length*a.options.item_height<b&&(k=a.scroll_elem.scrollTop=0);a.insertToDOM(e,g);a.scroll_elem.scrollTop=b};a.clear=function(){a.update([])};a.getRowsAmount=function(){return e.length};a.getScrollProgress=function(){return this.options.scroll_top/(e.length*this.options.item_height)*100||0};var w=function(b,c){var d=r(c)?c:[];d.length&&(e="append"==b?e.concat(d):d.concat(e),a.insertToDOM(e,g))};a.append=function(a){w("append",
|
||||
a)};a.prepend=function(a){w("prepend",a)}};p.prototype={constructor:p,fetchMarkup:function(){for(var b=[],a=this.getChildNodes(this.content_elem);a.length;)b.push(a.shift().outerHTML);return b},exploreEnvironment:function(b,a){var c=this.options;c.content_tag=this.content_elem.tagName.toLowerCase();b.length&&(l&&9>=l&&!c.tag&&(c.tag=b[0].match(/<([^>\s/]*)/)[1].toLowerCase()),1>=this.content_elem.children.length&&(a.data=this.html(b[0]+b[0]+b[0])),c.tag||(c.tag=this.content_elem.children[0].tagName.toLowerCase()),
|
||||
this.getRowsHeight(b))},getRowsHeight:function(b){var a=this.options,c=a.item_height;a.cluster_height=0;if(b.length){b=this.content_elem.children;var d=b[Math.floor(b.length/2)];a.item_height=d.offsetHeight;"tr"==a.tag&&"collapse"!=m("borderCollapse",this.content_elem)&&(a.item_height+=parseInt(m("borderSpacing",this.content_elem),10)||0);"tr"!=a.tag&&(b=parseInt(m("marginTop",d),10)||0,d=parseInt(m("marginBottom",d),10)||0,a.item_height+=Math.max(b,d));a.block_height=a.item_height*a.rows_in_block;
|
||||
a.rows_in_cluster=a.blocks_in_cluster*a.rows_in_block;a.cluster_height=a.blocks_in_cluster*a.block_height;return c!=a.item_height}},getClusterNum:function(){this.options.scroll_top=this.scroll_elem.scrollTop;return Math.floor(this.options.scroll_top/(this.options.cluster_height-this.options.block_height))||0},generateEmptyRow:function(){var b=this.options;if(!b.tag||!b.show_no_data_row)return[];var a=document.createElement(b.tag),c=document.createTextNode(b.no_data_text),d;a.className=b.no_data_class;
|
||||
"tr"==b.tag&&(d=document.createElement("td"),d.colSpan=100,d.appendChild(c));a.appendChild(d||c);return[a.outerHTML]},generate:function(b,a){var c=this.options,d=b.length;if(d<c.rows_in_block)return{top_offset:0,bottom_offset:0,rows_above:0,rows:d?b:this.generateEmptyRow()};var f=Math.max((c.rows_in_cluster-c.rows_in_block)*a,0),h=f+c.rows_in_cluster,e=Math.max(f*c.item_height,0),c=Math.max((d-h)*c.item_height,0),d=[],g=f;for(1>e&&g++;f<h;f++)b[f]&&d.push(b[f]);return{top_offset:e,bottom_offset:c,
|
||||
rows_above:g,rows:d}},renderExtraTag:function(b,a){var c=document.createElement(this.options.tag);c.className=["clusterize-extra-row","clusterize-"+b].join(" ");a&&(c.style.height=a+"px");return c.outerHTML},insertToDOM:function(b,a){this.options.cluster_height||this.exploreEnvironment(b,a);var c=this.generate(b,this.getClusterNum()),d=c.rows.join(""),f=this.checkChanges("data",d,a),h=this.checkChanges("top",c.top_offset,a),e=this.checkChanges("bottom",c.bottom_offset,a),g=this.options.callbacks,
|
||||
k=[];f||h?(c.top_offset&&(this.options.keep_parity&&k.push(this.renderExtraTag("keep-parity")),k.push(this.renderExtraTag("top-space",c.top_offset))),k.push(d),c.bottom_offset&&k.push(this.renderExtraTag("bottom-space",c.bottom_offset)),g.clusterWillChange&&g.clusterWillChange(),this.html(k.join("")),"ol"==this.options.content_tag&&this.content_elem.setAttribute("start",c.rows_above),g.clusterChanged&&g.clusterChanged()):e&&(this.content_elem.lastChild.style.height=c.bottom_offset+"px")},html:function(b){var a=
|
||||
this.content_elem;if(l&&9>=l&&"tr"==this.options.tag){var c=document.createElement("div");for(c.innerHTML="<table><tbody>"+b+"</tbody></table>";b=a.lastChild;)a.removeChild(b);for(c=this.getChildNodes(c.firstChild.firstChild);c.length;)a.appendChild(c.shift())}else a.innerHTML=b},getChildNodes:function(b){b=b.children;for(var a=[],c=0,d=b.length;c<d;c++)a.push(b[c]);return a},checkChanges:function(b,a,c){var d=a!=c[b];c[b]=a;return d}};return p});
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import "./lib/clusterize.min.js";
|
||||
import "./frappe/views/reports/report_factory.js";
|
||||
import "./frappe/views/reports/report_view.js";
|
||||
import "./frappe/views/reports/query_report.js";
|
||||
|
|
|
|||
|
|
@ -218,6 +218,10 @@
|
|||
|
||||
--awesomplete-hover-bg: var(--control-bg);
|
||||
|
||||
// Button Colors
|
||||
--btn-default-bg: var(--gray-100);
|
||||
--btn-default-hover-bg: var(--gray-300);
|
||||
|
||||
// Other Colors
|
||||
--sidebar-select-color: var(--gray-200);
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,25 @@
|
|||
color: var(--text-light);
|
||||
}
|
||||
|
||||
// font-size dropdown
|
||||
.ql-snow .ql-picker.ql-size {
|
||||
width: 58px;
|
||||
|
||||
&.ql-expanded {
|
||||
.ql-picker-options {
|
||||
overflow-y: auto;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-picker-label,
|
||||
.ql-picker-item {
|
||||
&:before {
|
||||
content: attr(data-value) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ql-snow .ql-picker-label {
|
||||
outline: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@
|
|||
--toast-bg: var(--modal-bg);
|
||||
--popover-bg: var(--bg-color);
|
||||
|
||||
// Button Colors
|
||||
--btn-default-bg: var(--gray-700);
|
||||
--btn-default-hover-bg: var(--gray-500);
|
||||
|
||||
// Background Text Color Pairs
|
||||
--bg-blue: var(--blue-600);
|
||||
--bg-light-blue: var(--blue-400);
|
||||
|
|
|
|||
|
|
@ -716,6 +716,75 @@ body {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.quick-list-widget-box {
|
||||
.list-loading-state, .list-no-data-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 202px;
|
||||
}
|
||||
|
||||
.refresh-list, .filter-list, .add-new {
|
||||
background-color: var(--btn-default-bg);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--btn-default-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.widget-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 6px 0px;
|
||||
min-height: 202px;
|
||||
|
||||
.quick-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 3px 0px;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--btn-default-bg);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
margin-left: 3px;
|
||||
|
||||
.timestamp {
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.right-arrow {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.widget-footer {
|
||||
.see-all {
|
||||
background-color: var(--btn-default-bg);
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--btn-default-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-success {
|
||||
|
|
@ -1002,7 +1071,7 @@ body {
|
|||
transition: visibility 0s, opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
.link-item, .quick-list-item, .see-all {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ a.card {
|
|||
|
||||
h4.modal-title {
|
||||
font-size: 1em;
|
||||
margin: 0px !important;
|
||||
}
|
||||
|
||||
h5.modal-title {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
.frappe-card {
|
||||
padding: 1rem;
|
||||
|
||||
h3 {
|
||||
h1 {
|
||||
font-size: 1.9rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class MATCH(DistinctOptionFunction):
|
|||
|
||||
if self._Against:
|
||||
return f"{s} AGAINST ({frappe.db.escape(f'+{self._Against}*')} IN BOOLEAN MODE)"
|
||||
return s
|
||||
raise Exception("Chain the `Against()` method with match to complete the query")
|
||||
|
||||
@builder
|
||||
def Against(self, text: str):
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
from pypika.functions import *
|
||||
from pypika.terms import Arithmetic, ArithmeticExpression, CustomFunction, Function
|
||||
|
||||
import frappe
|
||||
from frappe.database.query import Query
|
||||
from frappe.query_builder.custom import GROUP_CONCAT, MATCH, STRING_AGG, TO_TSVECTOR
|
||||
from frappe.query_builder.utils import ImportMapper, db_type_is
|
||||
|
||||
from .utils import Column
|
||||
from .utils import PseudoColumn
|
||||
|
||||
|
||||
class Concat_ws(Function):
|
||||
|
|
@ -45,7 +46,7 @@ DateFormat = ImportMapper(
|
|||
|
||||
class Cast_(Function):
|
||||
def __init__(self, value, as_type, alias=None):
|
||||
if db_type_is.MARIADB and (
|
||||
if frappe.db.db_type == "mariadb" and (
|
||||
(hasattr(as_type, "get_sql") and as_type.get_sql().lower() == "varchar")
|
||||
or str(as_type).lower() == "varchar"
|
||||
):
|
||||
|
|
@ -72,7 +73,10 @@ class Cast_(Function):
|
|||
|
||||
def _aggregate(function, dt, fieldname, filters, **kwargs):
|
||||
return (
|
||||
Query().build_conditions(dt, filters).select(function(Column(fieldname))).run(**kwargs)[0][0]
|
||||
Query()
|
||||
.build_conditions(dt, filters)
|
||||
.select(function(PseudoColumn(fieldname)))
|
||||
.run(**kwargs)[0][0]
|
||||
or 0
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -184,12 +184,10 @@ def get():
|
|||
frappe.get_attr(hook)(bootinfo=bootinfo)
|
||||
|
||||
bootinfo["lang"] = frappe.translate.get_user_lang()
|
||||
bootinfo["translated_search_doctypes"] = frappe.get_hooks("translated_search_doctypes")
|
||||
bootinfo["disable_async"] = frappe.conf.disable_async
|
||||
|
||||
bootinfo["setup_complete"] = cint(frappe.db.get_single_value("System Settings", "setup_complete"))
|
||||
bootinfo["is_first_startup"] = cint(
|
||||
frappe.db.get_single_value("System Settings", "is_first_startup")
|
||||
)
|
||||
|
||||
bootinfo["desk_theme"] = frappe.db.get_value("User", frappe.session.user, "desk_theme") or "Light"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,52 @@
|
|||
import ast
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
|
||||
import frappe
|
||||
from frappe.utils.boilerplate import make_boilerplate
|
||||
from frappe.utils.boilerplate import (
|
||||
_create_app_boilerplate,
|
||||
_get_user_inputs,
|
||||
github_workflow_template,
|
||||
)
|
||||
|
||||
|
||||
class TestBoilerPlate(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
title = "Test App"
|
||||
description = "This app's description contains 'single quotes' and \"double quotes\"."
|
||||
publisher = "Test Publisher"
|
||||
email = "example@example.org"
|
||||
icon = "" # empty -> default
|
||||
color = ""
|
||||
app_license = "MIT"
|
||||
cls.default_hooks = frappe._dict(
|
||||
{
|
||||
"app_name": "test_app",
|
||||
"app_title": "Test App",
|
||||
"app_description": "This app's description contains 'single quotes' and \"double quotes\".",
|
||||
"app_publisher": "Test Publisher",
|
||||
"app_email": "example@example.org",
|
||||
"app_license": "MIT",
|
||||
"create_github_workflow": False,
|
||||
}
|
||||
)
|
||||
|
||||
cls.user_input = [
|
||||
title,
|
||||
description,
|
||||
publisher,
|
||||
email,
|
||||
icon,
|
||||
color,
|
||||
app_license,
|
||||
]
|
||||
cls.default_user_input = frappe._dict(
|
||||
{
|
||||
"title": "Test App",
|
||||
"description": "This app's description contains 'single quotes' and \"double quotes\".",
|
||||
"publisher": "Test Publisher",
|
||||
"email": "example@example.org",
|
||||
"icon": "", # empty -> default
|
||||
"color": "",
|
||||
"app_license": "MIT",
|
||||
"github_workflow": "n",
|
||||
}
|
||||
)
|
||||
|
||||
cls.bench_path = frappe.utils.get_bench_path()
|
||||
cls.apps_dir = os.path.join(cls.bench_path, "apps")
|
||||
cls.app_names = ("test_app", "test_app_no_git")
|
||||
cls.gitignore_file = ".gitignore"
|
||||
cls.git_folder = ".git"
|
||||
|
||||
|
|
@ -55,39 +69,90 @@ class TestBoilerPlate(unittest.TestCase):
|
|||
"public",
|
||||
]
|
||||
|
||||
def create_app(self, hooks, no_git=False):
|
||||
self.addCleanup(self.delete_test_app, hooks.app_name)
|
||||
_create_app_boilerplate(self.apps_dir, hooks, no_git)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
test_app_dirs = (os.path.join(cls.bench_path, "apps", app_name) for app_name in cls.app_names)
|
||||
for test_app_dir in test_app_dirs:
|
||||
if os.path.exists(test_app_dir):
|
||||
shutil.rmtree(test_app_dir)
|
||||
def delete_test_app(cls, app_name):
|
||||
test_app_dir = os.path.join(cls.bench_path, "apps", app_name)
|
||||
if os.path.exists(test_app_dir):
|
||||
shutil.rmtree(test_app_dir)
|
||||
|
||||
@staticmethod
|
||||
def get_user_input_stream(inputs):
|
||||
user_inputs = []
|
||||
for value in inputs.values():
|
||||
if isinstance(value, list):
|
||||
user_inputs.extend(value)
|
||||
else:
|
||||
user_inputs.append(value)
|
||||
return StringIO("\n".join(user_inputs))
|
||||
|
||||
def test_simple_input_to_boilerplate(self):
|
||||
with patch("sys.stdin", self.get_user_input_stream(self.default_user_input)):
|
||||
hooks = _get_user_inputs(self.default_hooks.app_name)
|
||||
self.assertDictEqual(hooks, self.default_hooks)
|
||||
|
||||
def test_invalid_inputs(self):
|
||||
invalid_inputs = copy.copy(self.default_user_input).update(
|
||||
{
|
||||
"title": ["1nvalid Title", "valid title"],
|
||||
}
|
||||
)
|
||||
with patch("sys.stdin", self.get_user_input_stream(invalid_inputs)):
|
||||
hooks = _get_user_inputs(self.default_hooks.app_name)
|
||||
self.assertEqual(hooks.app_title, "valid title")
|
||||
|
||||
def test_valid_ci_yaml(self):
|
||||
yaml.safe_load(github_workflow_template.format(**self.default_hooks))
|
||||
|
||||
def test_create_app(self):
|
||||
with patch("builtins.input", side_effect=self.user_input):
|
||||
make_boilerplate(self.apps_dir, self.app_names[0])
|
||||
app_name = "test_app"
|
||||
|
||||
new_app_dir = os.path.join(self.bench_path, self.apps_dir, self.app_names[0])
|
||||
hooks = frappe._dict(
|
||||
{
|
||||
"app_name": app_name,
|
||||
"app_title": "Test App",
|
||||
"app_description": "This app's description contains 'single quotes' and \"double quotes\".",
|
||||
"app_publisher": "Test Publisher",
|
||||
"app_email": "example@example.org",
|
||||
"app_license": "MIT",
|
||||
}
|
||||
)
|
||||
|
||||
paths = self.get_paths(new_app_dir, self.app_names[0])
|
||||
self.create_app(hooks)
|
||||
new_app_dir = os.path.join(self.bench_path, self.apps_dir, app_name)
|
||||
|
||||
paths = self.get_paths(new_app_dir, app_name)
|
||||
for path in paths:
|
||||
self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {self.app_names[0]} app")
|
||||
self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {app_name} app")
|
||||
|
||||
self.check_parsable_python_files(new_app_dir)
|
||||
|
||||
def test_create_app_without_git_init(self):
|
||||
with patch("builtins.input", side_effect=self.user_input):
|
||||
make_boilerplate(self.apps_dir, self.app_names[1], no_git=True)
|
||||
app_name = "test_app_no_git"
|
||||
|
||||
new_app_dir = os.path.join(self.apps_dir, self.app_names[1])
|
||||
hooks = frappe._dict(
|
||||
{
|
||||
"app_name": app_name,
|
||||
"app_title": "Test App",
|
||||
"app_description": "This app's description contains 'single quotes' and \"double quotes\".",
|
||||
"app_publisher": "Test Publisher",
|
||||
"app_email": "example@example.org",
|
||||
"app_license": "MIT",
|
||||
}
|
||||
)
|
||||
self.create_app(hooks, no_git=True)
|
||||
|
||||
paths = self.get_paths(new_app_dir, self.app_names[1])
|
||||
new_app_dir = os.path.join(self.apps_dir, app_name)
|
||||
|
||||
paths = self.get_paths(new_app_dir, app_name)
|
||||
for path in paths:
|
||||
if os.path.basename(path) in (self.git_folder, self.gitignore_file):
|
||||
self.assertFalse(
|
||||
os.path.exists(path), msg=f"{path} shouldn't exist in {self.app_names[1]} app"
|
||||
)
|
||||
self.assertFalse(os.path.exists(path), msg=f"{path} shouldn't exist in {app_name} app")
|
||||
else:
|
||||
self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {self.app_names[1]} app")
|
||||
self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {app_name} app")
|
||||
|
||||
self.check_parsable_python_files(new_app_dir)
|
||||
|
||||
|
|
|
|||
|
|
@ -178,3 +178,50 @@ class TestClient(unittest.TestCase):
|
|||
# cleanup
|
||||
frappe.delete_doc("Note", note1.name)
|
||||
frappe.delete_doc("Note", note2.name)
|
||||
|
||||
def test_client_insert_many(self):
|
||||
from frappe.client import insert, insert_many
|
||||
|
||||
def get_random_title():
|
||||
return "test-{0}".format(frappe.generate_hash(length=5))
|
||||
|
||||
# insert a (parent) doc
|
||||
note1 = {"doctype": "Note", "title": get_random_title(), "content": "test"}
|
||||
note1 = insert(note1)
|
||||
|
||||
doc_list = [
|
||||
{
|
||||
"doctype": "Note Seen By",
|
||||
"user": "Administrator",
|
||||
"parenttype": "Note",
|
||||
"parent": note1.name,
|
||||
"parentfield": "seen_by",
|
||||
},
|
||||
{
|
||||
"doctype": "Note Seen By",
|
||||
"user": "Administrator",
|
||||
"parenttype": "Note",
|
||||
"parent": note1.name,
|
||||
"parentfield": "seen_by",
|
||||
},
|
||||
{
|
||||
"doctype": "Note Seen By",
|
||||
"user": "Administrator",
|
||||
"parenttype": "Note",
|
||||
"parent": note1.name,
|
||||
"parentfield": "seen_by",
|
||||
},
|
||||
{"doctype": "Note", "title": get_random_title(), "content": "test"},
|
||||
{"doctype": "Note", "title": get_random_title(), "content": "test"},
|
||||
]
|
||||
|
||||
# insert all docs
|
||||
docs = insert_many(doc_list)
|
||||
|
||||
# make sure only 1 name is returned for the parent upon insertion of child docs
|
||||
self.assertEqual(len(docs), 3)
|
||||
self.assertIn(note1.name, docs)
|
||||
|
||||
# cleanup
|
||||
for doc in docs:
|
||||
frappe.delete_doc("Note", doc)
|
||||
|
|
|
|||
|
|
@ -413,26 +413,6 @@ class TestCommands(BaseTestCommands):
|
|||
self.assertEqual(self.returncode, 0)
|
||||
self.assertEqual(check_password("Administrator", "test2"), "Administrator")
|
||||
|
||||
def test_make_app(self):
|
||||
user_input = [
|
||||
b"Test App", # title
|
||||
b"This app's description contains 'single quotes' and \"double quotes\".", # description
|
||||
b"Test Publisher", # publisher
|
||||
b"example@example.org", # email
|
||||
b"", # icon
|
||||
b"", # color
|
||||
b"MIT", # app_license
|
||||
]
|
||||
app_name = "testapp0"
|
||||
apps_path = os.path.join(get_bench_path(), "apps")
|
||||
test_app_path = os.path.join(apps_path, app_name)
|
||||
self.execute(f"bench make-app {apps_path} {app_name}", {"cmd_input": b"\n".join(user_input)})
|
||||
self.assertEqual(self.returncode, 0)
|
||||
self.assertTrue(os.path.exists(test_app_path))
|
||||
|
||||
# cleanup
|
||||
shutil.rmtree(test_app_path)
|
||||
|
||||
@skipIf(
|
||||
not (
|
||||
frappe.conf.root_password and frappe.conf.admin_password and frappe.conf.db_type == "mariadb"
|
||||
|
|
@ -460,6 +440,36 @@ class TestCommands(BaseTestCommands):
|
|||
archive_directory = os.path.join(bench_path, f"archived/sites/{site}")
|
||||
self.assertTrue(os.path.exists(archive_directory))
|
||||
|
||||
@skipIf(
|
||||
not (
|
||||
frappe.conf.root_password and frappe.conf.admin_password and frappe.conf.db_type == "mariadb"
|
||||
),
|
||||
"DB Root password and Admin password not set in config",
|
||||
)
|
||||
def test_force_install_app(self):
|
||||
if not os.path.exists(os.path.join(get_bench_path(), f"sites/{TEST_SITE}")):
|
||||
self.execute(
|
||||
f"bench new-site {TEST_SITE} --verbose "
|
||||
f"--admin-password {frappe.conf.admin_password} "
|
||||
f"--mariadb-root-password {frappe.conf.root_password} "
|
||||
f"--db-type {frappe.conf.db_type or 'mariadb'} "
|
||||
)
|
||||
|
||||
app_name = "frappe"
|
||||
|
||||
# set admin password in site_config as when frappe force installs, we don't have the conf
|
||||
self.execute(f"bench --site {TEST_SITE} set-config admin_password {frappe.conf.admin_password}")
|
||||
|
||||
# try installing the frappe_docs app again on test site
|
||||
self.execute(f"bench --site {TEST_SITE} install-app {app_name}")
|
||||
self.assertIn(f"{app_name} already installed", self.stdout)
|
||||
self.assertEqual(self.returncode, 0)
|
||||
|
||||
# force install frappe_docs app on the test site
|
||||
self.execute(f"bench --site {TEST_SITE} install-app {app_name} --force")
|
||||
self.assertIn(f"Installing {app_name}", self.stdout)
|
||||
self.assertEqual(self.returncode, 0)
|
||||
|
||||
|
||||
class TestBackups(BaseTestCommands):
|
||||
backup_map = {
|
||||
|
|
|
|||
|
|
@ -482,6 +482,33 @@ class TestDB(unittest.TestCase):
|
|||
|
||||
frappe.db.delete("ToDo", {"description": test_body})
|
||||
|
||||
def test_count(self):
|
||||
frappe.db.delete("Note")
|
||||
|
||||
frappe.get_doc(doctype="Note", title="note1", content="something").insert()
|
||||
frappe.get_doc(doctype="Note", title="note2", content="someting else").insert()
|
||||
|
||||
# Count with no filtes
|
||||
self.assertEquals((frappe.db.count("Note")), 2)
|
||||
|
||||
# simple filters
|
||||
self.assertEquals((frappe.db.count("Note", ["title", "=", "note1"])), 1)
|
||||
|
||||
frappe.get_doc(doctype="Note", title="note3", content="something other").insert()
|
||||
|
||||
# List of list filters with tables
|
||||
self.assertEquals(
|
||||
(
|
||||
frappe.db.count(
|
||||
"Note",
|
||||
[["Note", "title", "like", "note%"], ["Note", "content", "like", "some%"]],
|
||||
)
|
||||
),
|
||||
3,
|
||||
)
|
||||
|
||||
frappe.db.rollback()
|
||||
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestDDLCommandsMaria(unittest.TestCase):
|
||||
|
|
@ -524,10 +551,16 @@ class TestDDLCommandsMaria(unittest.TestCase):
|
|||
)
|
||||
|
||||
def test_change_type(self) -> None:
|
||||
def get_table_description():
|
||||
return frappe.db.sql(f"DESC `tab{self.test_table_name}`")
|
||||
|
||||
# try changing from int to varchar
|
||||
frappe.db.change_column_type("TestNotes", "id", "varchar(255)")
|
||||
test_table_description = frappe.db.sql(f"DESC tab{self.test_table_name};")
|
||||
self.assertGreater(len(test_table_description), 0)
|
||||
self.assertIn("varchar(255)", test_table_description[0])
|
||||
self.assertIn("varchar(255)", get_table_description()[0])
|
||||
|
||||
# try changing from varchar to bigint
|
||||
frappe.db.change_column_type("TestNotes", "id", "bigint")
|
||||
self.assertIn("bigint(20)", get_table_description()[0])
|
||||
|
||||
def test_add_index(self) -> None:
|
||||
index_name = "test_index"
|
||||
|
|
@ -736,21 +769,34 @@ class TestDDLCommandsPost(unittest.TestCase):
|
|||
self.assertEqual([("id",), ("content",)], frappe.db.describe(self.test_table_name))
|
||||
|
||||
def test_change_type(self) -> None:
|
||||
from psycopg2.errors import DatatypeMismatch
|
||||
|
||||
def get_table_description():
|
||||
return frappe.db.sql(
|
||||
f"""
|
||||
SELECT
|
||||
table_name,
|
||||
column_name,
|
||||
data_type
|
||||
FROM
|
||||
information_schema.columns
|
||||
WHERE
|
||||
table_name = 'tab{self.test_table_name}'"""
|
||||
)
|
||||
|
||||
# try changing from int to varchar
|
||||
frappe.db.change_column_type(self.test_table_name, "id", "varchar(255)")
|
||||
check_change = frappe.db.sql(
|
||||
f"""
|
||||
SELECT
|
||||
table_name,
|
||||
column_name,
|
||||
data_type
|
||||
FROM
|
||||
information_schema.columns
|
||||
WHERE
|
||||
table_name = 'tab{self.test_table_name}'
|
||||
"""
|
||||
)
|
||||
self.assertGreater(len(check_change), 0)
|
||||
self.assertIn("character varying", check_change[0])
|
||||
self.assertIn("character varying", get_table_description()[0])
|
||||
|
||||
# try changing from varchar to int
|
||||
try:
|
||||
frappe.db.change_column_type(self.test_table_name, "id", "bigint")
|
||||
except DatatypeMismatch:
|
||||
frappe.db.rollback()
|
||||
|
||||
# try changing from varchar to int (using cast)
|
||||
frappe.db.change_column_type(self.test_table_name, "id", "bigint", use_cast=True)
|
||||
self.assertIn("bigint", get_table_description()[0])
|
||||
|
||||
def test_add_index(self) -> None:
|
||||
index_name = "test_index"
|
||||
|
|
@ -765,7 +811,6 @@ class TestDDLCommandsPost(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(len(indexs_in_table), 1)
|
||||
|
||||
@run_only_if(db_type_is.POSTGRES)
|
||||
def test_modify_query(self):
|
||||
from frappe.database.postgres.database import modify_query
|
||||
|
||||
|
|
@ -783,7 +828,6 @@ class TestDDLCommandsPost(unittest.TestCase):
|
|||
modify_query(query),
|
||||
)
|
||||
|
||||
@run_only_if(db_type_is.POSTGRES)
|
||||
def test_modify_values(self):
|
||||
from frappe.database.postgres.database import modify_values
|
||||
|
||||
|
|
@ -814,3 +858,12 @@ class TestDDLCommandsPost(unittest.TestCase):
|
|||
)
|
||||
|
||||
dt.delete(ignore_permissions=True)
|
||||
|
||||
def test_is(self):
|
||||
user = frappe.qb.DocType("User")
|
||||
self.assertIn(
|
||||
"is not null", frappe.db.get_values(user, filters={user.name: ("is", "set")}, run=False).lower()
|
||||
)
|
||||
self.assertIn(
|
||||
"is null", frappe.db.get_values(user, filters={user.name: ("is", "not set")}, run=False).lower()
|
||||
)
|
||||
|
|
|
|||
20
frappe/tests/test_query.py
Normal file
20
frappe/tests/test_query.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.tests.test_query_builder import db_type_is, run_only_if
|
||||
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestQuery(unittest.TestCase):
|
||||
def test_multiple_tables_in_filters(self):
|
||||
self.assertEqual(
|
||||
frappe.db.query.get_sql(
|
||||
"DocType",
|
||||
["*"],
|
||||
[
|
||||
["BOM Update Log", "name", "like", "f%"],
|
||||
["DocType", "parent", "=", "something"],
|
||||
],
|
||||
).get_sql(),
|
||||
"SELECT * FROM `tabDocType` LEFT JOIN `tabBOM Update Log` ON `tabBOM Update Log`.`parent`=`tabDocType`.`name` WHERE `tabBOM Update Log`.`name` LIKE 'f%' AND `tabDocType`.`parent`='something'",
|
||||
)
|
||||
|
|
@ -3,6 +3,7 @@ from typing import Callable
|
|||
|
||||
import frappe
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.builder import Function
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Cast_, Coalesce, CombineDatetime, GroupConcat, Match
|
||||
from frappe.query_builder.utils import db_type_is
|
||||
|
|
@ -18,7 +19,10 @@ class TestCustomFunctionsMariaDB(unittest.TestCase):
|
|||
self.assertEqual("GROUP_CONCAT('Notes')", GroupConcat("Notes").get_sql())
|
||||
|
||||
def test_match(self):
|
||||
query = Match("Notes").Against("text")
|
||||
query = Match("Notes")
|
||||
with self.assertRaises(Exception):
|
||||
query.get_sql()
|
||||
query = query.Against("text")
|
||||
self.assertEqual(" MATCH('Notes') AGAINST ('+text*' IN BOOLEAN MODE)", query.get_sql())
|
||||
|
||||
def test_constant_column(self):
|
||||
|
|
@ -71,8 +75,12 @@ class TestCustomFunctionsMariaDB(unittest.TestCase):
|
|||
|
||||
def test_cast(self):
|
||||
note = frappe.qb.DocType("Note")
|
||||
self.assertEqual("CONCAT(`tabnote`.`name`, '')", Cast_(note.name, "varchar"))
|
||||
self.assertEqual("CAST(`tabnote`.`name` AS INTEGER)", Cast_(note.name, "integer"))
|
||||
self.assertEqual("CONCAT(name,'')", Cast_(note.name, "varchar").get_sql())
|
||||
self.assertEqual("CAST(name AS INTEGER)", Cast_(note.name, "integer").get_sql())
|
||||
self.assertEqual(
|
||||
frappe.qb.from_("red").from_(note).select("other", Cast_(note.name, "varchar")).get_sql(),
|
||||
"SELECT `tabred`.`other`,CONCAT(`tabNote`.`name`,'') FROM `tabred`,`tabNote`",
|
||||
)
|
||||
|
||||
|
||||
@run_only_if(db_type_is.POSTGRES)
|
||||
|
|
@ -81,6 +89,8 @@ class TestCustomFunctionsPostgres(unittest.TestCase):
|
|||
self.assertEqual("STRING_AGG('Notes',',')", GroupConcat("Notes").get_sql())
|
||||
|
||||
def test_match(self):
|
||||
query = Match("Notes")
|
||||
self.assertEqual("TO_TSVECTOR('Notes')", query.get_sql())
|
||||
query = Match("Notes").Against("text")
|
||||
self.assertEqual("TO_TSVECTOR('Notes') @@ PLAINTO_TSQUERY('text')", query.get_sql())
|
||||
|
||||
|
|
@ -132,8 +142,12 @@ class TestCustomFunctionsPostgres(unittest.TestCase):
|
|||
|
||||
def test_cast(self):
|
||||
note = frappe.qb.DocType("Note")
|
||||
self.assertEqual("CAST(`tabnote`.`name` AS VARCHAR)", Cast_(note.name, "varchar"))
|
||||
self.assertEqual("CAST(`tabnote`.`name` AS INTEGER)", Cast_(note.name, "integer"))
|
||||
self.assertEqual("CAST(name AS VARCHAR)", Cast_(note.name, "varchar").get_sql())
|
||||
self.assertEqual("CAST(name AS INTEGER)", Cast_(note.name, "integer").get_sql())
|
||||
self.assertEqual(
|
||||
frappe.qb.from_("red").from_(note).select("other", Cast_(note.name, "varchar")).get_sql(),
|
||||
'SELECT "tabred"."other",CAST("tabNote"."name" AS VARCHAR) FROM "tabred","tabNote"',
|
||||
)
|
||||
|
||||
|
||||
class TestBuilderBase(object):
|
||||
|
|
@ -149,6 +163,25 @@ class TestBuilderBase(object):
|
|||
self.assertIsInstance(query.run, Callable)
|
||||
self.assertIsInstance(data, list)
|
||||
|
||||
def test_agg_funcs(self):
|
||||
frappe.db.truncate("Communication")
|
||||
sample_data = {
|
||||
"doctype": "Communication",
|
||||
"communication_type": "Communication",
|
||||
"content": "testing",
|
||||
"rating": 1,
|
||||
}
|
||||
frappe.get_doc(sample_data).insert()
|
||||
sample_data["rating"] = 3
|
||||
frappe.get_doc(sample_data).insert()
|
||||
sample_data["rating"] = 4
|
||||
frappe.get_doc(sample_data).insert()
|
||||
self.assertEqual(frappe.qb.max("Communication", "rating"), 4)
|
||||
self.assertEqual(frappe.qb.min("Communication", "rating"), 1)
|
||||
self.assertAlmostEqual(frappe.qb.avg("Communication", "rating"), 2.666, places=2)
|
||||
self.assertEqual(frappe.qb.sum("Communication", "rating"), 8.0)
|
||||
frappe.db.rollback()
|
||||
|
||||
|
||||
class TestParameterization(unittest.TestCase):
|
||||
def test_where_conditions(self):
|
||||
|
|
@ -163,7 +196,7 @@ class TestParameterization(unittest.TestCase):
|
|||
self.assertIn("param1", params)
|
||||
self.assertEqual(params["param1"], "Administrator' --")
|
||||
|
||||
def test_set_cnoditions(self):
|
||||
def test_set_conditions(self):
|
||||
DocType = frappe.qb.DocType("DocType")
|
||||
query = frappe.qb.update(DocType).set(DocType.value, "some_value")
|
||||
|
||||
|
|
@ -230,6 +263,19 @@ class TestParameterization(unittest.TestCase):
|
|||
self.assertEqual(params["param4"], "true_value")
|
||||
self.assertEqual(params["param5"], "Overdue")
|
||||
|
||||
def test_named_parameter_wrapper(self):
|
||||
from frappe.query_builder.terms import NamedParameterWrapper
|
||||
|
||||
test_npw = NamedParameterWrapper()
|
||||
self.assertTrue(hasattr(test_npw, "parameters"))
|
||||
self.assertEqual(test_npw.get_sql("test_string_one"), "%(param1)s")
|
||||
self.assertEqual(test_npw.get_sql("test_string_two"), "%(param2)s")
|
||||
params = test_npw.get_parameters()
|
||||
for key in params.keys():
|
||||
# checks for param# format
|
||||
self.assertRegex(key, r"param\d")
|
||||
self.assertEqual(params["param1"], "test_string_one")
|
||||
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestBuilderMaria(unittest.TestCase, TestBuilderBase):
|
||||
|
|
@ -237,6 +283,12 @@ class TestBuilderMaria(unittest.TestCase, TestBuilderBase):
|
|||
self.assertEqual("SELECT * FROM `tabNotes`", frappe.qb.from_("Notes").select("*").get_sql())
|
||||
self.assertEqual("SELECT * FROM `__Auth`", frappe.qb.from_("__Auth").select("*").get_sql())
|
||||
|
||||
def test_get_qb_type(self):
|
||||
from frappe.query_builder import get_query_builder
|
||||
|
||||
qb = get_query_builder(frappe.db.db_type)
|
||||
self.assertEqual("SELECT * FROM `tabDocType`", qb().from_("DocType").select("*").get_sql())
|
||||
|
||||
|
||||
@run_only_if(db_type_is.POSTGRES)
|
||||
class TestBuilderPostgres(unittest.TestCase, TestBuilderBase):
|
||||
|
|
@ -253,3 +305,37 @@ class TestBuilderPostgres(unittest.TestCase, TestBuilderBase):
|
|||
|
||||
def test_replace_fields_post(self):
|
||||
self.assertEqual("relname", frappe.qb.Field("table_name").get_sql())
|
||||
|
||||
def test_get_qb_type(self):
|
||||
from frappe.query_builder import get_query_builder
|
||||
|
||||
qb = get_query_builder(frappe.db.db_type)
|
||||
self.assertEqual('SELECT * FROM "tabDocType"', qb().from_("DocType").select("*").get_sql())
|
||||
|
||||
|
||||
class TestMisc(unittest.TestCase):
|
||||
def test_custom_func(self):
|
||||
rand_func = frappe.qb.functions("rand", "45")
|
||||
self.assertIsInstance(rand_func, Function)
|
||||
self.assertEqual(rand_func.get_sql(), "rand('45')")
|
||||
|
||||
def test_function_with_schema(self):
|
||||
from frappe.query_builder import ParameterizedFunction
|
||||
|
||||
x = ParameterizedFunction("rand", "45")
|
||||
x.schema = frappe.qb.DocType("DocType")
|
||||
self.assertEqual("tabDocType.rand('45')", x.get_sql())
|
||||
|
||||
def test_util_table(self):
|
||||
from frappe.query_builder.utils import Table
|
||||
|
||||
DocType = Table("DocType")
|
||||
self.assertEqual(DocType.get_sql(), "DocType")
|
||||
|
||||
def test_error_on_query_class(self):
|
||||
import frappe.query_builder.utils
|
||||
|
||||
frappe.query_builder.utils.get_type_hints = lambda x: {"return": None}
|
||||
|
||||
with self.assertRaises(frappe.query_builder.utils.BuilderIdentificationFailed):
|
||||
frappe.query_builder.utils.patch_query_execute()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from unittest.mock import patch
|
|||
import frappe
|
||||
import frappe.translate
|
||||
from frappe import _
|
||||
from frappe.translate import get_language, get_parent_language
|
||||
from frappe.translate import get_language, get_parent_language, get_translation_dict_from_file
|
||||
from frappe.utils import set_request
|
||||
|
||||
dirname = os.path.dirname(__file__)
|
||||
|
|
@ -119,6 +119,23 @@ class TestTranslate(unittest.TestCase):
|
|||
return_val = get_language()
|
||||
self.assertNotIn(return_val, [third_lang, get_parent_language(third_lang)])
|
||||
|
||||
def test_load_all_translate_files(self):
|
||||
"""Load all CSV files to ensure they have correct format"""
|
||||
verify_translation_files("frappe")
|
||||
|
||||
|
||||
def verify_translation_files(app):
|
||||
"""Function to verify translation file syntax in app."""
|
||||
# Do not remove/rename this, other apps depend on it to test their translations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
translations_dir = Path(frappe.get_app_path(app)) / "translations"
|
||||
|
||||
for file in translations_dir.glob("*.csv"):
|
||||
lang = file.stem # basename of file = lang
|
||||
get_translation_dict_from_file(file, lang, app, throw=True)
|
||||
|
||||
|
||||
expected_output = [
|
||||
("Warning: Unable to find {0} in any table related to {1}", "This is some context", 2),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue