diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index 08d1d1aa9c..f8ee3fa10b 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -32,9 +32,9 @@ if __name__ == "__main__": if response.ok: payload = response.json() - title = payload.get("title", "").lower() - head_sha = payload.get("head", {}).get("sha") - body = payload.get("body", "").lower() + title = (payload.get("title") or "").lower() + head_sha = (payload.get("head") or {}).get("sha") + body = (payload.get("body") or "").lower() if title.startswith("feat") and head_sha and "no-docs" not in body: if docs_link_exists(body): diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index e8627a01fb..82be4d06b5 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -1,6 +1,11 @@ name: Patch -on: [pull_request, workflow_dispatch] +on: + pull_request: + paths-ignore: + - '**.js' + - '**.md' + workflow_dispatch: jobs: test: diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 2476102e3d..8d5bd690a1 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -2,9 +2,15 @@ name: Server on: pull_request: + paths-ignore: + - '**.js' + - '**.md' workflow_dispatch: push: branches: [ develop ] + paths-ignore: + - '**.js' + - '**.md' jobs: test: diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 4325eebaad..8c97c7f84b 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -2,6 +2,9 @@ name: Server on: pull_request: + paths-ignore: + - '**.js' + - '**.md' workflow_dispatch: jobs: diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index f342c0709e..d76e5e77ea 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -2,6 +2,8 @@ name: UI on: pull_request: + paths-ignore: + - '**.md' workflow_dispatch: push: branches: [ develop ] diff --git a/.mergify.yml b/.mergify.yml index c759c1e3ec..1a81a28594 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,4 +1,16 @@ pull_request_rules: + - name: Auto-close PRs on stable branch + conditions: + - or: + - base=version-13 + - base=version-12 + actions: + 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. + https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch + - name: Automatic merge on CI success and review conditions: - status-success=Sider diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index 3e12101532..fb09b384a8 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -10,9 +10,9 @@ context('Awesome Bar', () => { }); it('navigates to doctype list', () => { - cy.get('#navbar-search').type('todo', { delay: 200 }); - cy.get('#navbar-search + ul').should('be.visible'); - cy.get('#navbar-search').type('{downarrow}{enter}', { delay: 100 }); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 200 }); + cy.get('.awesomplete').findByRole('listbox').should('be.visible'); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 100 }); cy.get('.title-text').should('contain', 'To Do'); @@ -20,24 +20,24 @@ context('Awesome Bar', () => { }); it('find text in doctype list', () => { - cy.get('#navbar-search') + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') .type('test in todo{downarrow}{enter}', { delay: 200 }); cy.get('.title-text').should('contain', 'To Do'); - cy.get('[data-original-title="Name"] > .input-with-feedback') + cy.findByPlaceholderText('Name') .should('have.value', '%test%'); }); it('navigates to new form', () => { - cy.get('#navbar-search') + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') .type('new blog post{downarrow}{enter}', { delay: 200 }); cy.get('.title-text:visible').should('have.text', 'New Blog Post'); }); it('calculates math expressions', () => { - cy.get('#navbar-search') + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') .type('55 + 32{downarrow}{enter}', { delay: 200 }); cy.get('.modal-title').should('contain', 'Result'); diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js index 1df5e64f0e..5f1ab86d41 100644 --- a/cypress/integration/control_barcode.js +++ b/cypress/integration/control_barcode.js @@ -20,7 +20,7 @@ context('Control Barcode', () => { it('should generate barcode on setting a value', () => { get_dialog_with_barcode().as('dialog'); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .focus() .type('123456789') .blur(); @@ -37,11 +37,11 @@ context('Control Barcode', () => { it('should reset when input is cleared', () => { get_dialog_with_barcode().as('dialog'); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .focus() .type('123456789') .blur(); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .clear() .blur(); cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]') diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js index f92927f267..5c531a0823 100644 --- a/cypress/integration/control_icon.js +++ b/cypress/integration/control_icon.js @@ -17,17 +17,17 @@ context('Control Icon', () => { it('should set icon', () => { get_dialog_with_icon().as('dialog'); - cy.get('.frappe-control[data-fieldname=icon] input').first().click(); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').click(); cy.get('.icon-picker .icon-wrapper[id=active]').first().click(); - cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'active'); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'active'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); expect(value).to.equal('active'); }); cy.get('.icon-picker .icon-wrapper[id=resting]').first().click(); - cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'resting'); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'resting'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); expect(value).to.equal('resting'); @@ -36,14 +36,14 @@ context('Control Icon', () => { it('search for icon and clear search input', () => { let search_text = 'ed'; - cy.get('.icon-picker input[type=search]').first().click().type(search_text); + cy.get('.icon-picker').findByRole('searchbox').click().type(search_text); cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => { cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => { expect(i.length).to.equal(icons.length); }); }); - cy.get('.icon-picker input[type=search]').clear().blur(); + cy.get('.icon-picker').findByRole('searchbox').clear().blur(); cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden'); }); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 8f9257e9c4..7d44a71d06 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -35,7 +35,7 @@ context('Control Link', () => { cy.wait('@search_link'); cy.get('@input').type('todo for link', { delay: 200 }); cy.wait('@search_link'); - cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); + cy.get('.frappe-control[data-fieldname=link]').findByRole('listbox').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 => { @@ -71,7 +71,7 @@ context('Control Link', () => { cy.get('@input').type(todos[0]).blur(); cy.wait('@validate_link'); cy.get('@input').focus(); - cy.get('.frappe-control[data-fieldname=link] .link-btn') + cy.findByTitle('Open Link') .should('be.visible') .click(); cy.location('pathname').should('eq', `/app/todo/${todos[0]}`); diff --git a/cypress/integration/control_select.js b/cypress/integration/control_select.js index 0bc719b4a7..8e18d21260 100644 --- a/cypress/integration/control_select.js +++ b/cypress/integration/control_select.js @@ -24,8 +24,10 @@ context('Control Select', () => { cy.get('@control').get('.select-icon').should('exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'block'); cy.get('@select').select('Option 1'); + cy.findByDisplayValue('Option 1').should('exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'none'); cy.get('@select').invoke('val', ''); + cy.findByDisplayValue('Option 1').should('not.exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'block'); diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js index d33babb134..9aa6b5d89d 100644 --- a/cypress/integration/depends_on.js +++ b/cypress/integration/depends_on.js @@ -62,11 +62,11 @@ context('Depends On', () => { it('should set the field as mandatory depending on other fields value', () => { cy.new_form('Test Depends On'); cy.fill_field('test_field', 'Some Value'); - cy.get('button.primary-action').contains('Save').click(); + cy.findByRole('button', {name: 'Save'}).click(); cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('be.visible'); cy.hide_dialog(); cy.fill_field('test_field', 'Random value'); - cy.get('button.primary-action').contains('Save').click(); + cy.findByRole('button', {name: 'Save'}).click(); cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('not.be.visible'); }); it('should set the field as read only depending on other fields value', () => { @@ -84,7 +84,7 @@ context('Depends On', () => { cy.fill_field('dependant_field', 'Some Value'); //cy.fill_field('test_field', 'Some Other Value'); cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as('table'); - cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').findByRole('button', {name: 'Add Row'}).click(); cy.get('@table').find('[data-idx="1"]').as('row1'); cy.get('@row1').find('.btn-open-row').click(); cy.get('@row1').find('.form-in-grid').as('row1-form_in_grid'); diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js index e1e232c058..3d4f92df3c 100644 --- a/cypress/integration/file_uploader.js +++ b/cypress/integration/file_uploader.js @@ -25,7 +25,7 @@ context('FileUploader', () => { cy.get_open_dialog().find('.file-name').should('contain', 'example.json'); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-modal-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.statusCode').should('eq', 200); cy.get('.modal:visible').should('not.exist'); }); @@ -33,11 +33,11 @@ context('FileUploader', () => { it('should accept uploaded files', () => { open_upload_dialog(); - cy.get_open_dialog().find('.btn-file-upload div:contains("Library")').click(); - cy.get('.file-filter').type('example.json'); - cy.get_open_dialog().find('.tree-label:contains("example.json")').first().click(); + cy.get_open_dialog().findByRole('button', {name: 'Library'}).click(); + cy.findByPlaceholderText('Search by filename or extension').type('example.json'); + cy.get_open_dialog().findAllByText('example.json').first().click(); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.body.message') .should('have.property', 'file_name', 'example.json'); cy.get('.modal:visible').should('not.exist'); @@ -46,10 +46,12 @@ context('FileUploader', () => { it('should accept web links', () => { open_upload_dialog(); - cy.get_open_dialog().find('.btn-file-upload div:contains("Link")').click(); - cy.get_open_dialog().find('.file-web-link input').type('https://github.com', { delay: 100, force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Link'}).click(); + cy.get_open_dialog() + .findByPlaceholderText('Attach a web link') + .type('https://github.com', { delay: 100, force: true }); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.body.message') .should('have.property', 'file_url', 'https://github.com'); cy.get('.modal:visible').should('not.exist'); @@ -62,15 +64,14 @@ context('FileUploader', () => { subjectType: 'drag-n-drop', }); - cy.get_open_dialog().find('.file-name').should('contain', 'sample_image.jpg'); + cy.get_open_dialog().findAllByText('sample_image.jpg').should('exist'); cy.get_open_dialog().find('.btn-crop').first().click(); - cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').should('contain', 'Crop'); - cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').click(); - cy.get_open_dialog().find('.optimize-checkbox').first().should('contain', 'Optimize'); - cy.get_open_dialog().find('.optimize-checkbox').first().click(); + cy.get_open_dialog().findByRole('button', {name: 'Crop'}).click(); + cy.get_open_dialog().findAllByRole('checkbox', {name: 'Optimize'}).should('exist'); + cy.get_open_dialog().findAllByLabelText('Optimize').first().click(); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-modal-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.statusCode').should('eq', 200); cy.get('.modal:visible').should('not.exist'); }); diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 909955c1df..d20750b1d5 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -26,7 +26,7 @@ context('Form', () => { cy.visit('/app/contact'); cy.add_filter(); cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true }); - cy.get('.filter-popover .apply-filters').click({ force: true }); + cy.findByRole('button', {name: 'Apply Filters'}).click({ force: true }); cy.visit('/app/contact/Test Form Contact 3'); cy.get('.prev-doc').should('be.visible').click(); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js index d12be63f3b..d2d39679a8 100644 --- a/cypress/integration/form_tour.js +++ b/cypress/integration/form_tour.js @@ -9,7 +9,7 @@ context('Form Tour', () => { const open_test_form_tour = () => { cy.visit('/app/form-tour/Test Form Tour'); - cy.get('button[data-label="Show%20Tour"]').should('be.visible').and('contain', 'Show Tour').as('show_tour'); + cy.findByRole('button', {name: 'Show Tour'}).should('be.visible').as('show_tour'); cy.get('@show_tour').click(); cy.wait(500); cy.url().should('include', '/app/contact'); @@ -23,7 +23,7 @@ context('Form Tour', () => { cy.get('#driver-popover-item').should('be.visible'); cy.get('.frappe-control[data-fieldname="first_name"]').as('first_name'); cy.get('@first_name').should('have.class', 'driver-highlighted-element'); - cy.get('.driver-next-btn').as('next_btn'); + cy.get('#driver-popover-item').findByRole('button', {name: 'Next'}).as('next_btn'); // next btn shouldn't move to next step, if first name is not entered cy.get('@next_btn').click(); @@ -39,7 +39,7 @@ context('Form Tour', () => { // assert field is highlighted cy.get('.frappe-control[data-fieldname="last_name"]').as('last_name'); cy.get('@last_name').should('have.class', 'driver-highlighted-element'); - + // after filling the field, next step should be highlighted cy.fill_field('last_name', 'Test Last Name', 'Data'); cy.wait(500); @@ -49,12 +49,12 @@ context('Form Tour', () => { // assert field is highlighted cy.get('.frappe-control[data-fieldname="phone_nos"]').as('phone_nos'); cy.get('@phone_nos').should('have.class', 'driver-highlighted-element'); - + // move to next step cy.wait(500); cy.get('@next_btn').click(); cy.wait(500); - + // assert add row btn is highlighted cy.get('@phone_nos').find('.grid-add-row').as('add_row'); cy.get('@add_row').should('have.class', 'driver-highlighted-element'); @@ -78,11 +78,11 @@ context('Form Tour', () => { // collapse row cy.get('.grid-row-open .grid-collapse-row').click(); cy.wait(500); - + // assert save btn is highlighted cy.get('.primary-action').should('have.class', 'driver-highlighted-element'); - cy.get('@next_btn').should('contain', 'Save'); + cy.wait(500); + cy.get('#driver-popover-item').findByRole('button', {name: 'Save'}).should('be.visible'); }); }); - \ No newline at end of file diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js index 8f6b79c1f4..c07230d2b8 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -30,12 +30,12 @@ context('Grid Pagination', () => { it('adds and deletes rows and changes page', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); - cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').findByRole('button', {name: 'Add Row'}).click(); cy.get('@table').find('.grid-body .row-index').should('contain', 1001); cy.get('@table').find('.current-page-number').should('contain', '21'); cy.get('@table').find('.total-page-number').should('contain', '21'); cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({ force: true }); - cy.get('@table').find('button.grid-remove-rows').click(); + cy.get('@table').findByRole('button', {name: 'Delete'}).click(); cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000); cy.get('@table').find('.current-page-number').should('contain', '20'); cy.get('@table').find('.total-page-number').should('contain', '20'); diff --git a/cypress/integration/list_view_settings.js b/cypress/integration/list_view_settings.js index 52512b911e..61d4b8aae5 100644 --- a/cypress/integration/list_view_settings.js +++ b/cypress/integration/list_view_settings.js @@ -17,9 +17,9 @@ context('List View Settings', () => { cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click(); cy.get('.modal-dialog').should('contain', 'DocType Settings'); - cy.get('input[data-fieldname="disable_count"]').check({ force: true }); - cy.get('input[data-fieldname="disable_sidebar_stats"]').check({ force: true }); - cy.get('button').filter(':visible').contains('Save').click(); + cy.findByLabelText('Disable Count').check({ force: true }); + cy.findByLabelText('Disable Sidebar Stats').check({ force: true }); + cy.findByRole('button', {name: 'Save'}).click(); cy.reload({ force: true }); @@ -29,8 +29,8 @@ context('List View Settings', () => { cy.get('.menu-btn-group button').click({ force: true }); cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click(); cy.get('.modal-dialog').should('contain', 'DocType Settings'); - cy.get('input[data-fieldname="disable_count"]').uncheck({ force: true }); - cy.get('input[data-fieldname="disable_sidebar_stats"]').uncheck({ force: true }); - cy.get('button').filter(':visible').contains('Save').click(); + cy.findByLabelText('Disable Count').uncheck({ force: true }); + cy.findByLabelText('Disable Sidebar Stats').uncheck({ force: true }); + cy.findByRole('button', {name: 'Save'}).click(); }); }); diff --git a/cypress/integration/login.js b/cypress/integration/login.js index 6b109dd18d..98739bb4c9 100644 --- a/cypress/integration/login.js +++ b/cypress/integration/login.js @@ -11,13 +11,13 @@ context('Login', () => { it('validates password', () => { cy.get('#login_email').type('Administrator'); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/login'); }); it('validates email', () => { cy.get('#login_password').type('qwe'); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/login'); }); @@ -25,8 +25,8 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type('qwer'); - cy.get('.btn-login:visible').click(); - cy.get('.btn-login:visible').contains('Invalid Login. Try again.'); + cy.findByRole('button', {name: 'Login'}).click(); + cy.findByRole('button', {name: 'Invalid Login. Try again.'}).should('exist'); cy.location('pathname').should('eq', '/login'); }); @@ -34,7 +34,7 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/app'); cy.window().its('frappe.session.user').should('eq', 'Administrator'); }); @@ -60,7 +60,7 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); // verify redirected location and url params after login cy.url().should('include', '/me?' + payload.toString().replace('+', '%20')); diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js index 5b7692d8ff..7a62b2e6d9 100644 --- a/cypress/integration/recorder.js +++ b/cypress/integration/recorder.js @@ -16,24 +16,24 @@ context('Recorder', () => { it('Navigate to Recorder', () => { cy.visit('/app'); cy.awesomebar('recorder'); - cy.get('h3').should('contain', 'Recorder'); + cy.findByTitle('Recorder').should('exist'); cy.url().should('include', '/recorder/detail'); }); it('Recorder Empty State', () => { - cy.get('.title-text').should('contain', 'Recorder'); + cy.findByTitle('Recorder').should('exist'); cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red'); - cy.get('.primary-action').should('contain', 'Start'); - cy.get('.btn-secondary').should('contain', 'Clear'); + cy.findByRole('button', {name: 'Start'}).should('exist'); + cy.findByRole('button', {name: 'Clear'}).should('exist'); cy.get('.msg-box').should('contain', 'Inactive'); - cy.get('.msg-box .btn-primary').should('contain', 'Start Recording'); + cy.findByRole('button', {name: 'Start Recording'}).should('exist'); }); it('Recorder Start', () => { - cy.get('.primary-action').should('contain', 'Start').click(); + cy.findByRole('button', {name: 'Start'}).click(); cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green'); cy.get('.msg-box').should('contain', 'No Requests'); @@ -46,12 +46,12 @@ context('Recorder', () => { cy.get('.list-count').should('contain', '20 of '); cy.visit('/app/recorder'); - cy.get('.title-text').should('contain', 'Recorder'); + cy.findByTitle('Recorder').should('exist'); cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get'); }); it('Recorder View Request', () => { - cy.get('.primary-action').should('contain', 'Start').click(); + cy.findByRole('button', {name: 'Start'}).click(); cy.visit('/app/List/DocType/List'); cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index ea76246ae2..e762eebea1 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -23,7 +23,7 @@ context('Report View', () => { let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); // select the cell cell.dblclick(); - cell.find('input[data-fieldname="enabled"]').check({ force: true }); + cell.findByRole('checkbox').check({ force: true }); cy.get('.dt-row-0 > .dt-cell--col-5').click(); cy.wait('@value-update'); cy.get('@doc').then(doc => { diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index c7bbe29e5a..7a8f3a159b 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -10,26 +10,26 @@ context('Timeline', () => { it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => { //Adding new ToDo cy.click_listview_primary_button('Add ToDo'); - cy.get('.modal-footer > .custom-actions > .btn').contains('Edit in full page').click(); - cy.get('.row > .section-body > .form-column > form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').eq(0).type('Test ToDo', {force: true}); + cy.findByRole('button', {name: 'Edit in full page'}).click(); + cy.get('[data-fieldname="description"] .ql-editor').eq(0).type('Test ToDo', {force: true}); cy.wait(200); - cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .primary-action').contains('Save').click(); + cy.findByRole('button', {name: 'Save'}).click(); cy.wait(700); cy.visit('/app/todo'); - cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click(); + cy.get('.level-item.ellipsis').eq(0).click(); //To check if the comment box is initially empty and tying some text into it - cy.get('.comment-input-container > .frappe-control > .ql-container > .ql-editor').should('contain', '').type('Testing Timeline'); + cy.get('[data-fieldname="comment"] .ql-editor').should('contain', '').type('Testing Timeline'); //Adding new comment - cy.get('.comment-input-wrapper > .btn').contains('Comment').click(); + cy.findByRole('button', {name: 'Comment'}).click(); //To check if the commented text is visible in the timeline content cy.get('.timeline-content').should('contain', 'Testing Timeline'); //Editing comment cy.click_timeline_action_btn(0); - cy.get('.timeline-content > .timeline-message-box > .comment-edit-box > .frappe-control > .ql-container > .ql-editor').first().type(' 123'); + cy.get('.timeline-content [data-fieldname="comment"] .ql-editor').first().type(' 123'); cy.click_timeline_action_btn(0); //To check if the edited comment text is visible in timeline content @@ -37,20 +37,20 @@ context('Timeline', () => { //Discarding comment cy.click_timeline_action_btn(0); - cy.get('.actions > .btn').eq(1).first().click(); + cy.findByRole('button', {name: 'Dismiss'}).click(); //To check if after discarding the timeline content is same as previous cy.get('.timeline-content').should('contain', 'Testing Timeline 123'); //Deleting the added comment cy.get('.actions > .btn > .icon').first().click(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click(); + cy.findByRole('button', {name: 'Yes'}).click(); cy.click_modal_primary_button('Yes'); //Deleting the added ToDo - cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click({force: true}); - cy.get('.menu-btn-group > .dropdown-menu > li > .grey-link').eq(17).click({force: true}); - cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').contains('Yes').click({force: true}); + cy.get('.menu-btn-group button').eq(1).click(); + cy.get('.menu-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); }); it('Timeline should have submit and cancel activity information', () => { @@ -64,31 +64,31 @@ context('Timeline', () => { //Adding a new entry for the created custom doctype cy.fill_field('title', 'Test'); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Save').click(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Submit').click(); + cy.findByRole('button', {name: 'Save'}).click(); + cy.findByRole('button', {name: 'Submit'}).click(); cy.visit('/app/custom-submittable-doctype'); cy.get('.list-subject > .bold > .ellipsis').eq(0).click(); //To check if the submission of the documemt is visible in the timeline content cy.get('.timeline-content').should('contain', 'Administrator submitted this document'); - cy.get('.page-actions > .standard-actions > .btn-secondary').contains('Cancel').click({delay: 900}); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click(); - + cy.findByRole('button', {name: 'Cancel'}).click({delay: 900}); + cy.findByRole('button', {name: 'Yes'}).click(); + //To check if the cancellation of the documemt is visible in the timeline content cy.get('.timeline-content').should('contain', 'Administrator cancelled this document'); //Deleting the document cy.visit('/app/custom-submittable-doctype'); cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); - cy.get('.page-actions > .standard-actions > .actions-btn-group > .btn').contains('Actions').click(); + cy.findByRole('button', {name: 'Actions'}).click(); cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(7).click(); cy.click_modal_primary_button('Yes', {force: true, delay: 700}); //Deleting the custom doctype cy.visit('/app/doctype'); cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); - cy.get('.page-actions > .standard-actions > .actions-btn-group > .btn').contains('Actions').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); cy.click_modal_primary_button('Yes'); }); }); \ No newline at end of file diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js new file mode 100644 index 0000000000..f18e48aadc --- /dev/null +++ b/cypress/integration/workspace.js @@ -0,0 +1,90 @@ +context('Workspace 2.0', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/website'); + }); + + it('Navigate to page from sidebar', () => { + cy.visit('/app/build'); + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.sidebar-item-container[item-name="Settings"]').first().click(); + cy.location('pathname').should('eq', '/app/settings'); + }); + + it('Create Private Page', () => { + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); + cy.fill_field('title', 'Test Private 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 pubic section + cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0'); + + cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + cy.wait(300); + cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0'); + + cy.wait(500); + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); + }); + + it('Add New Block', () => { + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); + cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Heading').click(); + cy.get(".ce-block:last").find('h2').click({force: true}).type('Header'); + cy.get(".ce-block:last").find('.ce-header').should('exist'); + + cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); + cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Text').click(); + cy.get(".ce-block:last").find('.ce-paragraph').click({force: true}).type('Paragraph text'); + cy.get(".ce-block:last").find('.ce-paragraph').should('exist'); + }); + + it('Delete A Block', () => { + cy.get(".ce-block:last").find('.delete-paragraph').click(); + cy.get(".ce-block:last").find('.ce-paragraph').should('not.exist'); + }); + + it('Shrink and Expand A Block', () => { + cy.get(".ce-block:last").find('.tune-btn').click(); + cy.get('.ce-settings--opened .ce-shrink-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-11'); + cy.get('.ce-settings--opened .ce-shrink-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-10'); + cy.get('.ce-settings--opened .ce-shrink-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-9'); + cy.get('.ce-settings--opened .ce-expand-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-10'); + cy.get('.ce-settings--opened .ce-expand-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-11'); + cy.get('.ce-settings--opened .ce-expand-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-12'); + }); + + it('Change Header Text Size', () => { + cy.get('.ce-settings--opened .cdx-settings-button[data-level="3"]').click(); + cy.get(".ce-block:last").find('.widget-head h3').should('exist'); + cy.get('.ce-settings--opened .cdx-settings-button[data-level="4"]').click(); + cy.get(".ce-block:last").find('.widget-head h4').should('exist'); + + cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + }); + + it('Delete Private Page', () => { + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); + + cy.get('.sidebar-item-container[item-name="Test Private Page"]').find('.sidebar-item-control .delete-page').click(); + cy.wait(300); + cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click(); + cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('not.exist'); + }); + +}); \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index a81ba60fb0..c941652487 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,4 +1,5 @@ import 'cypress-file-upload'; +import '@testing-library/cypress/add-commands'; // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite diff --git a/frappe/__init__.py b/frappe/__init__.py index b4728f9ac3..6d79cbd760 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -28,7 +28,7 @@ from .exceptions import * from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader) from .utils.lazy_loader import lazy_import -from frappe.query_builder import get_query_builder +from frappe.query_builder import get_query_builder, patch_query_execute # Lazy imports faker = lazy_import('faker') @@ -208,6 +208,7 @@ def init(site, sites_path=None, new_site=False): local.qb = get_query_builder(local.conf.db_type or "mariadb") setup_module_map() + patch_query_execute() local.initialised = True diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index 896a10dfe0..80f2255f47 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -30,7 +30,7 @@ frappe.ui.form.on('Auto Repeat', { refresh: function(frm) { // auto repeat message if (frm.is_new()) { - let customize_form_link = `${__('Customize Form')}`; + let customize_form_link = `${__('Customize Form')}`; frm.dashboard.set_headline(__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [customize_form_link])); } diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json index 4a0835657b..f556be1c07 100644 --- a/frappe/automation/workspace/tools/tools.json +++ b/frappe/automation/workspace/tools/tools.json @@ -1,22 +1,27 @@ { - "category": "Administration", + "category": "", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"ToDo\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Note\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"File\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Assignment Rule\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Auto Repeat\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Tools\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Automation\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Event Streaming\", \"col\": 4}}]", "creation": "2020-03-02 14:53:24.980279", "developer_mode_only": 0, "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", + "extends": "", "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "tool", "idx": 0, - "is_standard": 1, + "is_default": 0, + "is_standard": 0, "label": "Tools", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Tools", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -25,6 +30,7 @@ "hidden": 0, "is_query_report": 0, "label": "To Do", + "link_count": 0, "link_to": "ToDo", "link_type": "DocType", "onboard": 1, @@ -35,6 +41,7 @@ "hidden": 0, "is_query_report": 0, "label": "Calendar", + "link_count": 0, "link_to": "Event", "link_type": "DocType", "onboard": 1, @@ -45,6 +52,7 @@ "hidden": 0, "is_query_report": 0, "label": "Note", + "link_count": 0, "link_to": "Note", "link_type": "DocType", "onboard": 1, @@ -55,6 +63,7 @@ "hidden": 0, "is_query_report": 0, "label": "Files", + "link_count": 0, "link_to": "File", "link_type": "DocType", "onboard": 0, @@ -65,6 +74,7 @@ "hidden": 0, "is_query_report": 0, "label": "Activity", + "link_count": 0, "link_to": "activity", "link_type": "Page", "onboard": 0, @@ -74,6 +84,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -82,6 +93,7 @@ "hidden": 0, "is_query_report": 0, "label": "Newsletter", + "link_count": 0, "link_to": "Newsletter", "link_type": "DocType", "onboard": 1, @@ -92,6 +104,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email Group", + "link_count": 0, "link_to": "Email Group", "link_type": "DocType", "onboard": 0, @@ -101,6 +114,7 @@ "hidden": 0, "is_query_report": 0, "label": "Automation", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -109,6 +123,7 @@ "hidden": 0, "is_query_report": 0, "label": "Assignment Rule", + "link_count": 0, "link_to": "Assignment Rule", "link_type": "DocType", "onboard": 0, @@ -119,6 +134,7 @@ "hidden": 0, "is_query_report": 0, "label": "Milestone", + "link_count": 0, "link_to": "Milestone", "link_type": "DocType", "onboard": 0, @@ -129,6 +145,7 @@ "hidden": 0, "is_query_report": 0, "label": "Auto Repeat", + "link_count": 0, "link_to": "Auto Repeat", "link_type": "DocType", "onboard": 0, @@ -138,6 +155,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Streaming", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -146,6 +164,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Producer", + "link_count": 0, "link_to": "Event Producer", "link_type": "DocType", "onboard": 0, @@ -156,6 +175,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Consumer", + "link_count": 0, "link_to": "Event Consumer", "link_type": "DocType", "onboard": 0, @@ -166,6 +186,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Update Log", + "link_count": 0, "link_to": "Event Update Log", "link_type": "DocType", "onboard": 0, @@ -176,6 +197,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Sync Log", + "link_count": 0, "link_to": "Event Sync Log", "link_type": "DocType", "onboard": 0, @@ -186,19 +208,26 @@ "hidden": 0, "is_query_report": 0, "label": "Document Type Mapping", + "link_count": 0, "link_to": "Document Type Mapping", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2020-12-01 13:38:39.950350", + "modified": "2021-08-05 12:16:02.839180", "modified_by": "Administrator", "module": "Automation", "name": "Tools", + "onboarding": "", "owner": "Administrator", + "parent_page": "", "pin_to_bottom": 0, "pin_to_top": 0, + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 26, "shortcuts": [ { "label": "ToDo", @@ -225,5 +254,6 @@ "link_to": "Auto Repeat", "type": "DocType" } - ] + ], + "title": "Tools" } \ No newline at end of file diff --git a/frappe/boot.py b/frappe/boot.py index 0589e32ac8..c46709d3d7 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -105,8 +105,8 @@ def load_conf_settings(bootinfo): if key in conf: bootinfo[key] = conf.get(key) def load_desktop_data(bootinfo): - from frappe.desk.desktop import get_desk_sidebar_items - bootinfo.allowed_workspaces = get_desk_sidebar_items() + from frappe.desk.desktop import get_wspace_sidebar_items + bootinfo.allowed_workspaces = get_wspace_sidebar_items().get('pages') bootinfo.module_page_map = get_controller("Workspace").get_module_page_map() bootinfo.dashboards = frappe.get_all("Dashboard") diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index c17ae583ed..2ee3b46b7c 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -145,10 +145,9 @@ def build_table_count_cache(): table_rows = frappe.qb.Field("table_rows").as_("count") information_schema = frappe.qb.Schema("information_schema") - query = frappe.qb.from_(information_schema.tables).select(table_name, table_rows) - - data = frappe.db.sql(query, as_dict=1) - + data = ( + frappe.qb.from_(information_schema.tables).select(table_name, table_rows) + ).run(as_dict=True) counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data} _cache.set_value("information_schema:counts", counts) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index f2395ae490..be8304e45d 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -589,24 +589,26 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None): admin_password = frappe.get_conf(site).admin_password # override baseUrl using env variable - site_env = 'CYPRESS_baseUrl={}'.format(site_url) - password_env = 'CYPRESS_adminPassword={}'.format(admin_password) if admin_password else '' + site_env = f'CYPRESS_baseUrl={site_url}' + password_env = f'CYPRESS_adminPassword={admin_password}' if admin_password else '' os.chdir(app_base_path) node_bin = subprocess.getoutput("npm bin") - cypress_path = "{0}/cypress".format(node_bin) - plugin_path = "{0}/../cypress-file-upload".format(node_bin) + cypress_path = f"{node_bin}/cypress" + plugin_path = f"{node_bin}/../cypress-file-upload" + testing_library_path = f"{node_bin}/../@testing-library" # check if cypress in path...if not, install it. if not ( os.path.exists(cypress_path) and os.path.exists(plugin_path) + and os.path.exists(testing_library_path) and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6 ): # install cypress click.secho("Installing Cypress...", fg="yellow") - frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile") + frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 --no-lockfile") # run for headless mode run_or_open = 'run --browser firefox --record' if headless else 'open' @@ -617,7 +619,7 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None): formatted_command += ' --parallel' if ci_build_id: - formatted_command += ' --ci-build-id {}'.format(ci_build_id) + formatted_command += f' --ci-build-id {ci_build_id}' click.secho("Running Cypress...", fg="yellow") frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True) diff --git a/frappe/config/__init__.py b/frappe/config/__init__.py index e7f0f1a763..aa441b7d71 100644 --- a/frappe/config/__init__.py +++ b/frappe/config/__init__.py @@ -43,9 +43,13 @@ def get_all_empty_tables_by_module(): table_name = frappe.qb.Field("table_name") information_schema = frappe.qb.Schema("information_schema") - query = frappe.qb.from_(information_schema.tables).select(table_name).where(table_rows == 0) + empty_tables = ( + frappe.qb.from_(information_schema.tables) + .select(table_name) + .where(table_rows == 0) + ).run() - empty_tables = {r[0] for r in frappe.db.sql(query)} + empty_tables = {r[0] for r in empty_tables} results = frappe.get_all("DocType", fields=["name", "module"]) empty_tables_by_module = {} diff --git a/frappe/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json index cf8a180e27..b77e7a6677 100644 --- a/frappe/core/doctype/feedback/feedback.json +++ b/frappe/core/doctype/feedback/feedback.json @@ -8,8 +8,8 @@ "reference_doctype", "reference_name", "column_break_3", - "email", "rating", + "ip_address", "section_break_6", "feedback" ], @@ -18,12 +18,6 @@ "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "fieldname": "email", - "fieldtype": "Data", - "label": "Email", - "reqd": 1 - }, { "fieldname": "rating", "fieldtype": "Float", @@ -56,11 +50,18 @@ "label": "Reference Name", "options": "reference_doctype", "reqd": 1 + }, + { + "fieldname": "ip_address", + "fieldtype": "Data", + "hidden": 1, + "label": "IP Address", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-06-14 15:11:26.005805", + "modified": "2021-06-23 12:45:42.045696", "modified_by": "Administrator", "module": "Core", "name": "Feedback", diff --git a/frappe/core/doctype/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py index 702f9d8ac1..2a96d86874 100644 --- a/frappe/core/doctype/feedback/test_feedback.py +++ b/frappe/core/doctype/feedback/test_feedback.py @@ -12,12 +12,12 @@ class TestFeedback(unittest.TestCase): frappe.db.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'") from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback - feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback','test@test.com') + feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback') self.assertEqual(feedback.feedback, 'New feedback') self.assertEqual(feedback.rating, 5) - updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback', 'test@test.com') + updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback') self.assertEqual(updated_feedback.feedback, 'Updated feedback') self.assertEqual(updated_feedback.rating, 6) diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json index aefda698b1..464052ba39 100644 --- a/frappe/core/workspace/build/build.json +++ b/frappe/core/workspace/build/build.json @@ -1,24 +1,28 @@ { "cards_label": "Elements", - "category": "Modules", + "category": "", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"DocType\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Workspace\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Report\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Elements\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Modules\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Models\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Views\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Scripting\", \"col\": 4}}]", "creation": "2021-01-02 10:51:16.579957", "developer_mode_only": 0, "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", + "extends": "", "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "tool", "idx": 0, "is_default": 0, - "is_standard": 1, + "is_standard": 0, "label": "Build", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Modules", + "link_count": 0, "link_type": "DocType", "onboard": 0, "only_for": "", @@ -28,6 +32,7 @@ "hidden": 0, "is_query_report": 0, "label": "Module Def", + "link_count": 0, "link_to": "Module Def", "link_type": "DocType", "onboard": 0, @@ -38,6 +43,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workspace", + "link_count": 0, "link_to": "Workspace", "link_type": "DocType", "onboard": 0, @@ -48,6 +54,7 @@ "hidden": 0, "is_query_report": 0, "label": "Module Onboarding", + "link_count": 0, "link_to": "Module Onboarding", "link_type": "DocType", "onboard": 0, @@ -58,6 +65,7 @@ "hidden": 0, "is_query_report": 0, "label": "Block Module", + "link_count": 0, "link_to": "Block Module", "link_type": "DocType", "onboard": 0, @@ -68,6 +76,7 @@ "hidden": 0, "is_query_report": 0, "label": "Models", + "link_count": 0, "link_type": "DocType", "onboard": 0, "only_for": "", @@ -77,6 +86,7 @@ "hidden": 0, "is_query_report": 0, "label": "DocType", + "link_count": 0, "link_to": "DocType", "link_type": "DocType", "onboard": 0, @@ -87,6 +97,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow", + "link_count": 0, "link_to": "Workflow", "link_type": "DocType", "onboard": 0, @@ -97,6 +108,7 @@ "hidden": 0, "is_query_report": 0, "label": "Views", + "link_count": 0, "link_type": "DocType", "onboard": 0, "only_for": "", @@ -106,6 +118,7 @@ "hidden": 0, "is_query_report": 0, "label": "Report", + "link_count": 0, "link_to": "Report", "link_type": "DocType", "onboard": 0, @@ -116,6 +129,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Format", + "link_count": 0, "link_to": "Print Format", "link_type": "DocType", "onboard": 0, @@ -126,6 +140,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workspace", + "link_count": 0, "link_to": "Workspace", "link_type": "DocType", "onboard": 0, @@ -136,6 +151,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dashboard", + "link_count": 0, "link_to": "Dashboard", "link_type": "DocType", "onboard": 0, @@ -146,6 +162,7 @@ "hidden": 0, "is_query_report": 0, "label": "Scripting", + "link_count": 0, "link_type": "DocType", "onboard": 0, "only_for": "", @@ -155,6 +172,7 @@ "hidden": 0, "is_query_report": 0, "label": "Server Script", + "link_count": 0, "link_to": "Server Script", "link_type": "DocType", "onboard": 0, @@ -165,6 +183,7 @@ "hidden": 0, "is_query_report": 0, "label": "Client Script", + "link_count": 0, "link_to": "Client Script", "link_type": "DocType", "onboard": 0, @@ -175,6 +194,7 @@ "hidden": 0, "is_query_report": 0, "label": "Scheduled Job Type", + "link_count": 0, "link_to": "Scheduled Job Type", "link_type": "DocType", "onboard": 0, @@ -182,13 +202,19 @@ "type": "Link" } ], - "modified": "2021-02-04 13:48:48.493146", + "modified": "2021-08-05 12:15:55.793022", "modified_by": "Administrator", "module": "Core", "name": "Build", + "onboarding": "", "owner": "Administrator", + "parent_page": "", "pin_to_bottom": 0, "pin_to_top": 0, + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 5, "shortcuts": [ { "doc_view": "", @@ -208,5 +234,6 @@ "link_to": "Report", "type": "DocType" } - ] + ], + "title": "Build" } \ No newline at end of file diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json index fb26b73cfc..93a6c81c90 100644 --- a/frappe/core/workspace/settings/settings.json +++ b/frappe/core/workspace/settings/settings.json @@ -1,22 +1,27 @@ { - "category": "Modules", + "category": "", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Settings\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"System Settings\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Print Settings\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Website Settings\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Data\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email / Notifications\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Website\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Core\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Printing\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Workflow\", \"col\": 4}}]", "creation": "2020-03-02 15:09:40.527211", "developer_mode_only": 0, - "disable_user_customization": 1, + "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", + "extends": "", "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "setting", "idx": 0, - "is_standard": 1, + "is_default": 0, + "is_standard": 0, "label": "Settings", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Data", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -25,6 +30,7 @@ "hidden": 0, "is_query_report": 0, "label": "Import Data", + "link_count": 0, "link_to": "Data Import", "link_type": "DocType", "onboard": 0, @@ -35,6 +41,7 @@ "hidden": 0, "is_query_report": 0, "label": "Export Data", + "link_count": 0, "link_to": "Data Export", "link_type": "DocType", "onboard": 0, @@ -45,6 +52,7 @@ "hidden": 0, "is_query_report": 0, "label": "Bulk Update", + "link_count": 0, "link_to": "Bulk Update", "link_type": "DocType", "onboard": 0, @@ -55,6 +63,7 @@ "hidden": 0, "is_query_report": 0, "label": "Download Backups", + "link_count": 0, "link_to": "backups", "link_type": "Page", "onboard": 0, @@ -65,6 +74,7 @@ "hidden": 0, "is_query_report": 0, "label": "Deleted Documents", + "link_count": 0, "link_to": "Deleted Document", "link_type": "DocType", "onboard": 0, @@ -74,6 +84,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email / Notifications", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -82,6 +93,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email Account", + "link_count": 0, "link_to": "Email Account", "link_type": "DocType", "onboard": 0, @@ -92,6 +104,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email Domain", + "link_count": 0, "link_to": "Email Domain", "link_type": "DocType", "onboard": 0, @@ -102,6 +115,7 @@ "hidden": 0, "is_query_report": 0, "label": "Notification", + "link_count": 0, "link_to": "Notification", "link_type": "DocType", "onboard": 0, @@ -112,6 +126,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email Template", + "link_count": 0, "link_to": "Email Template", "link_type": "DocType", "onboard": 0, @@ -122,6 +137,7 @@ "hidden": 0, "is_query_report": 0, "label": "Auto Email Report", + "link_count": 0, "link_to": "Auto Email Report", "link_type": "DocType", "onboard": 0, @@ -132,6 +148,7 @@ "hidden": 0, "is_query_report": 0, "label": "Newsletter", + "link_count": 0, "link_to": "Newsletter", "link_type": "DocType", "onboard": 0, @@ -142,6 +159,7 @@ "hidden": 0, "is_query_report": 0, "label": "Notification Settings", + "link_count": 0, "link_to": "Notification Settings", "link_type": "DocType", "onboard": 0, @@ -151,6 +169,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -159,6 +178,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Settings", + "link_count": 0, "link_to": "Website Settings", "link_type": "DocType", "onboard": 1, @@ -169,6 +189,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Theme", + "link_count": 0, "link_to": "Website Theme", "link_type": "DocType", "onboard": 1, @@ -179,6 +200,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Script", + "link_count": 0, "link_to": "Website Script", "link_type": "DocType", "onboard": 0, @@ -189,6 +211,7 @@ "hidden": 0, "is_query_report": 0, "label": "About Us Settings", + "link_count": 0, "link_to": "About Us Settings", "link_type": "DocType", "onboard": 0, @@ -199,6 +222,7 @@ "hidden": 0, "is_query_report": 0, "label": "Contact Us Settings", + "link_count": 0, "link_to": "Contact Us Settings", "link_type": "DocType", "onboard": 0, @@ -208,6 +232,7 @@ "hidden": 0, "is_query_report": 0, "label": "Core", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -216,6 +241,7 @@ "hidden": 0, "is_query_report": 0, "label": "System Settings", + "link_count": 0, "link_to": "System Settings", "link_type": "DocType", "onboard": 0, @@ -226,6 +252,7 @@ "hidden": 0, "is_query_report": 0, "label": "Error Log", + "link_count": 0, "link_to": "Error Log", "link_type": "DocType", "onboard": 0, @@ -236,6 +263,7 @@ "hidden": 0, "is_query_report": 0, "label": "Error Snapshot", + "link_count": 0, "link_to": "Error Snapshot", "link_type": "DocType", "onboard": 0, @@ -246,6 +274,7 @@ "hidden": 0, "is_query_report": 0, "label": "Domain Settings", + "link_count": 0, "link_to": "Domain Settings", "link_type": "DocType", "onboard": 0, @@ -255,6 +284,7 @@ "hidden": 0, "is_query_report": 0, "label": "Printing", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -263,6 +293,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Format Builder", + "link_count": 0, "link_to": "print-format-builder", "link_type": "Page", "onboard": 0, @@ -273,6 +304,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Settings", + "link_count": 0, "link_to": "Print Settings", "link_type": "DocType", "onboard": 0, @@ -283,6 +315,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Format", + "link_count": 0, "link_to": "Print Format", "link_type": "DocType", "onboard": 0, @@ -293,6 +326,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Style", + "link_count": 0, "link_to": "Print Style", "link_type": "DocType", "onboard": 0, @@ -302,6 +336,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -310,6 +345,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow", + "link_count": 0, "link_to": "Workflow", "link_type": "DocType", "onboard": 0, @@ -320,6 +356,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow State", + "link_count": 0, "link_to": "Workflow State", "link_type": "DocType", "onboard": 0, @@ -330,19 +367,26 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow Action", + "link_count": 0, "link_to": "Workflow Action", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2020-12-01 13:38:40.235323", + "modified": "2021-08-05 12:16:03.456173", "modified_by": "Administrator", "module": "Core", "name": "Settings", + "onboarding": "", "owner": "Administrator", - "pin_to_bottom": 1, + "parent_page": "", + "pin_to_bottom": 0, "pin_to_top": 0, + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 29, "shortcuts": [ { "icon": "setting", @@ -363,5 +407,6 @@ "type": "DocType" } ], - "shortcuts_label": "Settings" + "shortcuts_label": "Settings", + "title": "Settings" } \ No newline at end of file diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json index ba82461b57..09a835ea2c 100644 --- a/frappe/core/workspace/users/users.json +++ b/frappe/core/workspace/users/users.json @@ -1,23 +1,27 @@ { - "category": "Administration", + "category": "", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Permission Manager\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Profile\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Type\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Users\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Logs\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Permissions\", \"col\": 4}}]", "creation": "2020-03-02 15:12:16.754449", "developer_mode_only": 0, "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", + "extends": "", "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "users", "idx": 0, "is_default": 0, - "is_standard": 1, + "is_standard": 0, "label": "Users", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Users", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -26,6 +30,7 @@ "hidden": 0, "is_query_report": 0, "label": "User", + "link_count": 0, "link_to": "User", "link_type": "DocType", "onboard": 0, @@ -36,6 +41,7 @@ "hidden": 0, "is_query_report": 0, "label": "Role", + "link_count": 0, "link_to": "Role", "link_type": "DocType", "onboard": 0, @@ -46,6 +52,7 @@ "hidden": 0, "is_query_report": 0, "label": "Role Profile", + "link_count": 0, "link_to": "Role Profile", "link_type": "DocType", "onboard": 0, @@ -55,6 +62,7 @@ "hidden": 0, "is_query_report": 0, "label": "Logs", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -63,6 +71,7 @@ "hidden": 0, "is_query_report": 0, "label": "Activity Log", + "link_count": 0, "link_to": "Activity Log", "link_type": "DocType", "onboard": 0, @@ -73,6 +82,7 @@ "hidden": 0, "is_query_report": 0, "label": "Access Log", + "link_count": 0, "link_to": "Access Log", "link_type": "DocType", "onboard": 0, @@ -82,6 +92,7 @@ "hidden": 0, "is_query_report": 0, "label": "Permissions", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -90,6 +101,7 @@ "hidden": 0, "is_query_report": 0, "label": "Role Permissions Manager", + "link_count": 0, "link_to": "permission-manager", "link_type": "Page", "onboard": 0, @@ -100,6 +112,7 @@ "hidden": 0, "is_query_report": 0, "label": "User Permissions", + "link_count": 0, "link_to": "User Permission", "link_type": "DocType", "onboard": 0, @@ -110,6 +123,7 @@ "hidden": 0, "is_query_report": 0, "label": "Role Permission for Page and Report", + "link_count": 0, "link_to": "Role Permission for Page and Report", "link_type": "DocType", "onboard": 0, @@ -120,6 +134,7 @@ "hidden": 0, "is_query_report": 1, "label": "Permitted Documents For User", + "link_count": 0, "link_to": "Permitted Documents For User", "link_type": "Report", "onboard": 0, @@ -130,19 +145,26 @@ "hidden": 0, "is_query_report": 0, "label": "Document Share Report", + "link_count": 0, "link_to": "Document Share Report", "link_type": "Report", "onboard": 0, "type": "Link" } ], - "modified": "2021-03-25 23:02:34.582569", + "modified": "2021-08-05 12:16:03.010204", "modified_by": "Administrator", "module": "Core", "name": "Users", + "onboarding": "", "owner": "Administrator", + "parent_page": "", "pin_to_bottom": 0, "pin_to_top": 0, + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 27, "shortcuts": [ { "label": "User", @@ -170,5 +192,6 @@ "link_to": "User Type", "type": "DocType" } - ] + ], + "title": "Users" } \ No newline at end of file diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json index cdc3b73366..136b1a57eb 100644 --- a/frappe/custom/workspace/customization/customization.json +++ b/frappe/custom/workspace/customization/customization.json @@ -1,23 +1,27 @@ { - "category": "Administration", + "category": "", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Customize Form\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Custom Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Client Script\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Server Script\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Dashboards\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Form Customization\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Other\", \"col\": 4}}]", "creation": "2020-03-02 15:15:03.839594", "developer_mode_only": 0, "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", + "extends": "", "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "customization", "idx": 0, "is_default": 0, - "is_standard": 1, + "is_standard": 0, "label": "Customization", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Dashboards", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -26,6 +30,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dashboard", + "link_count": 0, "link_to": "Dashboard", "link_type": "DocType", "onboard": 0, @@ -36,6 +41,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dashboard Chart", + "link_count": 0, "link_to": "Dashboard Chart", "link_type": "DocType", "onboard": 0, @@ -46,6 +52,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dashboard Chart Source", + "link_count": 0, "link_to": "Dashboard Chart Source", "link_type": "DocType", "onboard": 0, @@ -55,6 +62,7 @@ "hidden": 0, "is_query_report": 0, "label": "Form Customization", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -63,6 +71,7 @@ "hidden": 0, "is_query_report": 0, "label": "Customize Form", + "link_count": 0, "link_to": "Customize Form", "link_type": "DocType", "onboard": 0, @@ -73,6 +82,7 @@ "hidden": 0, "is_query_report": 0, "label": "Custom Field", + "link_count": 0, "link_to": "Custom Field", "link_type": "DocType", "onboard": 0, @@ -83,6 +93,7 @@ "hidden": 0, "is_query_report": 0, "label": "Client Script", + "link_count": 0, "link_to": "Client Script", "link_type": "DocType", "onboard": 0, @@ -93,6 +104,7 @@ "hidden": 0, "is_query_report": 0, "label": "DocType", + "link_count": 0, "link_to": "DocType", "link_type": "DocType", "onboard": 0, @@ -102,6 +114,7 @@ "hidden": 0, "is_query_report": 0, "label": "Other", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -110,19 +123,26 @@ "hidden": 0, "is_query_report": 0, "label": "Custom Translations", + "link_count": 0, "link_to": "Translation", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2021-02-04 13:50:35.750463", + "modified": "2021-08-05 12:15:57.486112", "modified_by": "Administrator", "module": "Custom", "name": "Customization", + "onboarding": "", "owner": "Administrator", + "parent_page": "", "pin_to_bottom": 0, "pin_to_top": 0, + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 8, "shortcuts": [ { "label": "Customize Form", @@ -145,5 +165,6 @@ "link_to": "Server Script", "type": "DocType" } - ] + ], + "title": "Customization" } \ No newline at end of file diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index ca53e6cba4..e9036b98b0 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -6,6 +6,7 @@ import frappe from json import loads, dumps from frappe import _, DoesNotExistError, ValidationError, _dict from frappe.boot import get_allowed_pages, get_allowed_reports +from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles from functools import wraps from frappe.cache_manager import ( build_domain_restriced_doctype_cache, @@ -27,18 +28,21 @@ def handle_not_exist(fn): class Workspace: - def __init__(self, page_name, minimal=False): - self.page_name = page_name + def __init__(self, page, minimal=False): + self.page_name = page.get('name') + self.page_title = page.get('title') + self.public_page = page.get('public') self.extended_links = [] self.extended_charts = [] self.extended_shortcuts = [] + self.workspace_manager = "Workspace Manager" in frappe.get_roles() self.user = frappe.get_user() self.allowed_modules = self.get_cached('user_allowed_modules', self.get_allowed_modules) - self.doc = self.get_page_for_user() + self.doc = frappe.get_cached_doc("Workspace", self.page_name) - if self.doc.module and self.doc.module not in self.allowed_modules: + if self.doc and self.doc.module and self.doc.module not in self.allowed_modules and not self.workspace_manager: raise frappe.PermissionError self.can_read = self.get_cached('user_perm_can_read', self.get_can_read_items) @@ -47,16 +51,17 @@ class Workspace: self.allowed_reports = get_allowed_reports(cache=True) if not minimal: - self.onboarding_doc = self.get_onboarding_doc() - self.onboarding = None + if self.doc.content: + self.onboarding_list = [x['data']['onboarding_name'] for x in loads(self.doc.content) if x['type'] == 'onboarding'] + self.onboardings = [] self.table_counts = get_table_with_counts() self.restricted_doctypes = frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache() self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache() def is_page_allowed(self): - cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) + self.extended_links - shortcuts = self.doc.shortcuts + self.extended_shortcuts + cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) + shortcuts = self.doc.shortcuts for section in cards: links = loads(section.get('links')) if isinstance(section.get('links'), str) else section.get('links') @@ -74,8 +79,28 @@ class Workspace: if self.is_item_allowed(item.link_to, item.type) and _in_active_domains(item): return True + if not shortcuts and not self.doc.links: + return True + return False + def is_permitted(self): + """Returns true if Has Role is not set or the user is allowed.""" + from frappe.utils import has_common + + allowed = [d.role for d in frappe.get_all("Has Role", fields=["role"], filters={"parent": self.doc.name})] + + custom_roles = get_custom_allowed_roles('page', self.doc.name) + allowed.extend(custom_roles) + + if not allowed: + return True + + roles = frappe.get_roles() + + if has_common(roles, allowed): + return True + def get_cached(self, cache_key, fallback_fn): _cache = frappe.cache() @@ -101,39 +126,18 @@ class Workspace: return self.user.allow_modules - def get_page_for_user(self): - filters = { - 'extends': self.page_name, - 'for_user': frappe.session.user - } - user_pages = frappe.get_all("Workspace", filters=filters, limit=1) - if user_pages: - return frappe.get_cached_doc("Workspace", user_pages[0]) - - filters = { - 'extends_another_page': 1, - 'extends': self.page_name, - 'is_default': 1 - } - default_page = frappe.get_all("Workspace", filters=filters, limit=1) - if default_page: - return frappe.get_cached_doc("Workspace", default_page[0]) - - self.get_pages_to_extend() - return frappe.get_cached_doc("Workspace", self.page_name) - - def get_onboarding_doc(self): + def get_onboarding_doc(self, onboarding): # Check if onboarding is enabled if not frappe.get_system_settings("enable_onboarding"): return None - if not self.doc.onboarding: + if not self.onboarding_list: return None - if frappe.db.get_value("Module Onboarding", self.doc.onboarding, "is_complete"): + if frappe.db.get_value("Module Onboarding", onboarding, "is_complete"): return None - doc = frappe.get_doc("Module Onboarding", self.doc.onboarding) + doc = frappe.get_doc("Module Onboarding", onboarding) # Check if user is allowed allowed_roles = set(doc.get_allowed_roles()) @@ -197,14 +201,9 @@ class Workspace: 'items': self.get_shortcuts() } - if self.onboarding_doc: - self.onboarding = { - 'label': _(self.onboarding_doc.title), - 'subtitle': _(self.onboarding_doc.subtitle), - 'success': _(self.onboarding_doc.success_message), - 'docs_url': self.onboarding_doc.documentation_url, - 'items': self.get_onboarding_steps() - } + self.onboardings = { + 'items': self.get_onboardings() + } def _doctype_contains_a_record(self, name): exists = self.table_counts.get(name, False) @@ -333,9 +332,26 @@ class Workspace: return items @handle_not_exist - def get_onboarding_steps(self): + def get_onboardings(self): + if self.onboarding_list: + for onboarding in self.onboarding_list: + onboarding_doc = self.get_onboarding_doc(onboarding) + if onboarding_doc: + item = { + 'label': _(onboarding), + 'title': _(onboarding_doc.title), + 'subtitle': _(onboarding_doc.subtitle), + 'success': _(onboarding_doc.success_message), + 'docs_url': onboarding_doc.documentation_url, + 'items': self.get_onboarding_steps(onboarding_doc) + } + self.onboardings.append(item) + return self.onboardings + + @handle_not_exist + def get_onboarding_steps(self, onboarding_doc): steps = [] - for doc in self.onboarding_doc.get_steps(): + for doc in onboarding_doc.get_steps(): step = doc.as_dict().copy() step.label = _(doc.title) if step.action == "Create Entry": @@ -352,19 +368,19 @@ def get_desktop_page(page): on desk. Args: - page (string): page name + page (json): page data Returns: dict: dictionary of cards, charts and shortcuts to be displayed on website """ try: - wspace = Workspace(page) + wspace = Workspace(loads(page)) wspace.build_workspace() return { 'charts': wspace.charts, 'shortcuts': wspace.shortcuts, 'cards': wspace.cards, - 'onboarding': wspace.onboarding, + 'onboardings': wspace.onboardings, 'allow_customization': not wspace.doc.disable_user_customization } except DoesNotExistError: @@ -372,39 +388,45 @@ def get_desktop_page(page): return {} @frappe.whitelist() -def get_desk_sidebar_items(): +def get_wspace_sidebar_items(): """Get list of sidebar items for desk""" + has_access = "Workspace Manager" in frappe.get_roles() # don't get domain restricted pages blocked_modules = frappe.get_doc('User', frappe.session.user).get_blocked_modules() + blocked_modules.append('Dummy Module') filters = { 'restrict_to_domain': ['in', frappe.get_active_domains()], - 'extends_another_page': 0, - 'for_user': '', 'module': ['not in', blocked_modules] } - if not frappe.local.conf.developer_mode: - filters['developer_mode_only'] = '0' + if has_access: + filters = [] - # pages sorted based on pinned to top and then by name - order_by = "pin_to_top desc, pin_to_bottom asc, name asc" - all_pages = frappe.get_all("Workspace", fields=["name", "category", "icon", "module"], - filters=filters, order_by=order_by, ignore_permissions=True) + # pages sorted based on sequence id + order_by = "sequence_id asc" + fields = ["name", "title", "for_user", "parent_page", "content", "public", "module", "icon"] + all_pages = frappe.get_all("Workspace", fields=fields, filters=filters, order_by=order_by, ignore_permissions=True) pages = [] + private_pages = [] # Filter Page based on Permission for page in all_pages: try: - wspace = Workspace(page.get('name'), True) - if wspace.is_page_allowed(): - pages.append(page) + wspace = Workspace(page) + if wspace.is_permitted() and wspace.is_page_allowed() or has_access: + if page.public: + pages.append(page) + elif page.for_user == frappe.session.user: + private_pages.append(page) page['label'] = _(page.get('name')) except frappe.PermissionError: pass + if private_pages: + pages.extend(private_pages) - return pages + return {'pages': pages, 'has_access': has_access} def get_table_with_counts(): counts = frappe.cache().get_value("information_schema:counts") @@ -471,7 +493,7 @@ def get_custom_workspace_for_user(page): """ filters = { 'extends': page, - 'for_user': frappe.session.user + 'for_user': frappe.session.user, } pages = frappe.get_list("Workspace", filters=filters) if pages: @@ -481,7 +503,6 @@ def get_custom_workspace_for_user(page): doc.for_user = frappe.session.user return doc - @frappe.whitelist() def save_customization(page, config): """Save customizations as a separate doctype in Workspace per user @@ -540,6 +561,80 @@ def save_customization(page, config): return True +def save_new_widget(doc, page, blocks, new_widgets): + + widgets = _dict(loads(new_widgets)) + + if widgets.chart: + 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.card: + doc.build_links_table_from_card(widgets.card) + + # remove duplicate and unwanted widgets + if widgets: + clean_up(doc, blocks) + + try: + doc.save(ignore_permissions=True) + except (ValidationError, TypeError) as e: + # Create a json string to log + json_config = dumps(widgets, sort_keys=True, indent=4) + + # Error log body + log = \ + """ + page: {0} + config: {1} + exception: {2} + """.format(page, json_config, e) + frappe.log_error(log, _("Could not save customization")) + return False + + return True +def clean_up(original_page, blocks): + page_widgets = {} + + for wid in ['shortcut', 'card', 'chart']: + # 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']: + updated_widgets = [] + original_page.get(wid+'s').reverse() + + for w in original_page.get(wid+'s'): + if w.label in page_widgets[wid] and w.label not in [x.label for x in updated_widgets]: + updated_widgets.append(w) + original_page.set(wid+'s', updated_widgets) + + # card cleanup + for i, v in enumerate(original_page.links): + if v.type == 'Card Break' and v.label not in page_widgets['card']: + del original_page.links[i : i+v.link_count+1] + +def new_widget(config, doctype, parentfield): + if not config: + return [] + prepare_widget_list = [] + for idx, widget in enumerate(config): + # Some cleanup + widget.pop("name", None) + + # New Doc + doc = frappe.new_doc(doctype) + doc.update(widget) + + # Manually Set IDX + doc.idx = idx + 1 + + # Set Parent Field + doc.parentfield = parentfield + + prepare_widget_list.append(doc) + return prepare_widget_list def prepare_widget(config, doctype, parentfield): """Create widget child table entries with parent details diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py index 10bd8926ce..2336ff52f8 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.py +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py @@ -2,10 +2,26 @@ # Copyright (c) 2020, Frappe Technologies and contributors # For license information, please see license.txt -# import frappe +import frappe +from frappe import _ +import json from frappe.model.document import Document class OnboardingStep(Document): def before_export(self, doc): doc.is_complete = 0 doc.is_skipped = 0 + + +@frappe.whitelist() +def get_onboarding_steps(ob_steps): + steps = [] + for s in json.loads(ob_steps): + doc = frappe.get_doc('Onboarding Step', s.get('step')) + step = doc.as_dict().copy() + step.label = _(doc.title) + if step.action == "Create Entry": + step.is_submittable = frappe.db.get_value("DocType", step.reference_document, 'is_submittable', cache=True) + steps.append(step) + + return steps diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index 386267b699..e2ae38faf1 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -8,8 +8,11 @@ "engine": "InnoDB", "field_order": [ "label", + "title", + "sequence_id", "for_user", "extends", + "parent_page", "module", "category", "icon", @@ -24,6 +27,9 @@ "pin_to_top", "pin_to_bottom", "hide_custom", + "public", + "content_section", + "content", "section_break_2", "charts_label", "charts", @@ -32,7 +38,8 @@ "shortcuts", "section_break_18", "cards_label", - "links" + "links", + "roles" ], "fields": [ { @@ -199,7 +206,7 @@ }, { "fieldname": "icon", - "fieldtype": "Data", + "fieldtype": "Icon", "label": "Icon" }, { @@ -209,16 +216,53 @@ "options": "Workspace Link" }, { - "default": "0", - "depends_on": "extends_another_page", - "description": "Sets the current page as default for all users", - "fieldname": "is_default", - "fieldtype": "Check", - "label": "Is Default" - } + "default": "0", + "depends_on": "extends_another_page", + "description": "Sets the current page as default for all users", + "fieldname": "is_default", + "fieldtype": "Check", + "label": "Is Default" + }, + { + "default": "0", + "fieldname": "public", + "fieldtype": "Check", + "label": "Public" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title" + }, + { + "fieldname": "parent_page", + "fieldtype": "Data", + "label": "Parent Page" + }, + { + "fieldname": "content_section", + "fieldtype": "Section Break", + "label": "Content" + }, + { + "fieldname": "content", + "fieldtype": "Long Text", + "label": "Content" + }, + { + "fieldname": "sequence_id", + "fieldtype": "Int", + "label": "Sequence Id" + }, + { + "fieldname": "roles", + "fieldtype": "Table", + "label": "Roles", + "options": "Has Role" + } ], "links": [], - "modified": "2021-01-21 12:09:36.156614", + "modified": "2021-08-05 11:49:09.028243", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", @@ -232,7 +276,7 @@ "print": 1, "read": 1, "report": 1, - "role": "System Manager", + "role": "Workspace Manager", "share": 1, "write": 1 }, @@ -248,4 +292,4 @@ ], "sort_field": "modified", "sort_order": "DESC" -} +} \ No newline at end of file diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 41b0227f2a..0821ae03c4 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.modules.export_file import export_to_files from frappe.model.document import Document +from frappe.desk.desktop import save_new_widget from frappe.desk.utils import validate_route_conflict from json import loads @@ -27,7 +28,7 @@ class Workspace(Document): if disable_saving_as_standard(): return - if frappe.conf.developer_mode and self.is_standard: + if frappe.conf.developer_mode and self.module and self.public: export_to_files(record_list=[['Workspace', self.name]], record_module=self.module) @staticmethod @@ -98,6 +99,37 @@ class Workspace(Document): "is_query_report": link.get('is_query_report') }) + def build_links_table_from_card(self, config): + + for idx, card in enumerate(config): + links = loads(card.get('links')) + + # remove duplicate before adding + for idx, link in enumerate(self.links): + if link.label == card.get('label') and link.type == 'Card Break': + del self.links[idx : idx + link.link_count + 1] + + self.append('links', { + "label": card.get('label'), + "type": "Card Break", + "icon": card.get('icon'), + "hidden": card.get('hidden') or False, + "link_count": card.get('link_count'), + "idx": 1 if not self.links else self.links[-1].idx + 1 + }) + + for link in links: + self.append('links', { + "label": link.get('label'), + "type": "Link", + "link_type": link.get('link_type'), + "link_to": link.get('link_to'), + "onboard": link.get('onboard'), + "only_for": link.get('only_for'), + "dependencies": link.get('dependencies'), + "is_query_report": link.get('is_query_report'), + "idx": self.links[-1].idx + 1 + }) def disable_saving_as_standard(): return frappe.flags.in_install or \ @@ -123,3 +155,84 @@ def get_link_type(key): def get_report_type(report): report_type = frappe.get_value("Report", report, "report_type") return report_type in ["Query Report", "Script Report", "Custom Report"] + + +@frappe.whitelist() +def save_page(title, icon, parent, public, sb_public_items, sb_private_items, deleted_pages, new_widgets, blocks, save): + save = frappe.parse_json(save) + public = frappe.parse_json(public) + if save: + doc = frappe.new_doc('Workspace') + doc.title = title + doc.icon = icon + doc.content = blocks + doc.parent_page = parent + + if public: + doc.label = title + doc.public = 1 + else: + doc.label = title + "-" + frappe.session.user + doc.for_user = frappe.session.user + doc.save(ignore_permissions=True) + else: + if public: + filters = { + 'public': public, + 'label': title + } + else: + filters = { + 'for_user': frappe.session.user, + 'label': title + "-" + frappe.session.user + } + pages = frappe.get_list("Workspace", filters=filters) + if pages: + doc = frappe.get_doc("Workspace", pages[0]) + + doc.content = blocks + doc.save(ignore_permissions=True) + + if loads(new_widgets): + save_new_widget(doc, title, blocks, new_widgets) + + if loads(sb_public_items) or loads(sb_private_items): + sort_pages(loads(sb_public_items), loads(sb_private_items)) + + if loads(deleted_pages): + return delete_pages(loads(deleted_pages)) + + return {"name": title, "public": public} + +def delete_pages(deleted_pages): + for page in deleted_pages: + if page.get("public") and "Workspace Manager" not in frappe.get_roles(): + return {"name": page.get("title"), "public": 1} + + if frappe.db.exists("Workspace", page.get("name")): + frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True) + + return {"name": "Home", "public": 1} + +def sort_pages(sb_public_items, sb_private_items): + wspace_public_pages = get_page_list(['name', 'title'], {'public': 1}) + wspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user}) + + if sb_private_items: + sort_page(wspace_private_pages, sb_private_items) + + if sb_public_items and "Workspace Manager" in frappe.get_roles(): + sort_page(wspace_public_pages, sb_public_items) + +def sort_page(wspace_pages, pages): + for seq, d in enumerate(pages): + for page in wspace_pages: + if page.title == d.get('title'): + doc = frappe.get_doc('Workspace', page.name) + doc.sequence_id = seq + 1 + doc.parent_page = d.get('parent_page') or "" + doc.save(ignore_permissions=True) + break + +def get_page_list(fields, filters): + return frappe.get_list("Workspace", fields=fields, filters=filters, order_by='sequence_id asc') diff --git a/frappe/desk/doctype/workspace_link/workspace_link.json b/frappe/desk/doctype/workspace_link/workspace_link.json index 53dadad83d..a7b217be9e 100644 --- a/frappe/desk/doctype/workspace_link/workspace_link.json +++ b/frappe/desk/doctype/workspace_link/workspace_link.json @@ -8,15 +8,16 @@ "type", "label", "icon", - "only_for", "hidden", "link_details_section", "link_type", "link_to", "column_break_7", "dependencies", + "only_for", "onboard", - "is_query_report" + "is_query_report", + "link_count" ], "fields": [ { @@ -99,12 +100,19 @@ "fieldname": "is_query_report", "fieldtype": "Check", "label": "Is Query Report" + }, + { + "depends_on": "eval:doc.type == \"Card Break\"", + "fieldname": "link_count", + "fieldtype": "Int", + "hidden": 1, + "label": "Link Count" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-13 13:10:18.128512", + "modified": "2021-06-01 11:23:28.990593", "modified_by": "Administrator", "module": "Desk", "name": "Workspace Link", diff --git a/frappe/email/doctype/newsletter/exceptions.py b/frappe/email/doctype/newsletter/exceptions.py new file mode 100644 index 0000000000..a6c688dbe8 --- /dev/null +++ b/frappe/email/doctype/newsletter/exceptions.py @@ -0,0 +1,13 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + +from frappe.exceptions import ValidationError + +class NewsletterAlreadySentError(ValidationError): + pass + +class NoRecipientFoundError(ValidationError): + pass + +class NewsletterNotSavedError(ValidationError): + pass diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 97d77549b7..667d0fb34c 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -1,241 +1,322 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + +from typing import Dict, List import frappe import frappe.utils -from frappe import throw, _ + +from frappe import _ from frappe.website.website_generator import WebsiteGenerator from frappe.utils.verified_command import get_signed_params, verify_request from frappe.email.doctype.email_group.email_group import add_subscribers -from frappe.utils import parse_addr, now_datetime, markdown, validate_email_address + +from .exceptions import NewsletterAlreadySentError, NoRecipientFoundError, NewsletterNotSavedError + class Newsletter(WebsiteGenerator): def onload(self): - if self.email_sent: - self.get("__onload").status_count = dict(frappe.db.sql("""select status, count(name) - from `tabEmail Queue` where reference_doctype=%s and reference_name=%s - group by status""", (self.doctype, self.name))) or None + self.setup_newsletter_status() def validate(self): - self.route = "newsletters/" + self.name - if self.send_from: - validate_email_address(self.send_from, True) + self.route = f"newsletters/{self.name}" + self.validate_sender_address() + self.validate_recipient_address() + + @property + def newsletter_recipients(self) -> List[str]: + if getattr(self, "_recipients", None) is None: + self._recipients = self.get_recipients() + return self._recipients @frappe.whitelist() - def test_send(self, doctype="Lead"): - self.recipients = frappe.utils.split_emails(self.test_email_id) - self.queue_all(test_email=True) + def test_send(self): + test_emails = frappe.utils.split_emails(self.test_email_id) + self.queue_all(test_emails=test_emails) frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id)) @frappe.whitelist() def send_emails(self): """send emails to leads and customers""" + self.queue_all() + frappe.msgprint(_("Email queued to {0} recipients").format(len(self.newsletter_recipients))) + + def setup_newsletter_status(self): + """Setup analytical status for current Newsletter. Can be accessible from desk. + """ if self.email_sent: - throw(_("Newsletter has already been sent")) - - self.recipients = self.get_recipients() - - if self.recipients: - self.queue_all() - frappe.msgprint(_("Email queued to {0} recipients").format(len(self.recipients))) - - else: - frappe.msgprint(_("Newsletter should have atleast one recipient")) - - def queue_all(self, test_email=False): - if not self.get("recipients"): - # in case it is called via worker - self.recipients = self.get_recipients() - - self.validate_send() - - sender = self.send_from or frappe.utils.get_formatted_email(self.owner) - - if not frappe.flags.in_test: - frappe.db.auto_commit_on_many_writes = True - - attachments = [] - if self.send_attachments: - files = frappe.get_all("File", fields=["name"], filters={"attached_to_doctype": "Newsletter", - "attached_to_name": self.name}, order_by="creation desc") - - for file in files: - try: - # these attachments will be attached on-demand - # and won't be stored in the message - attachments.append({"fid": file.name}) - except IOError: - frappe.throw(_("Unable to find attachment {0}").format(file.name)) - - args = { - "message": self.get_message(), - "name": self.name - } - frappe.sendmail(recipients=self.recipients, sender=sender, - subject=self.subject, message=self.get_message(), template="newsletter", - reference_doctype=self.doctype, reference_name=self.name, - add_unsubscribe_link=self.send_unsubscribe_link, attachments=attachments, - unsubscribe_method="/unsubscribe", - unsubscribe_params={"name": self.name}, - send_priority=0, queue_separately=True, args=args) - - if not frappe.flags.in_test: - frappe.db.auto_commit_on_many_writes = False - - if not test_email: - self.db_set("email_sent", 1) - self.db_set("schedule_send", now_datetime()) - self.db_set("scheduled_to_send", len(self.recipients)) - - def get_message(self): - if self.content_type == "HTML": - return frappe.render_template(self.message_html, {"doc": self.as_dict()}) - return { - 'Rich Text': self.message, - 'Markdown': markdown(self.message_md) - }[self.content_type or 'Rich Text'] - - def get_recipients(self): - """Get recipients from Email Group""" - recipients_list = [] - for email_group in get_email_groups(self.name): - for d in frappe.db.get_all("Email Group Member", ["email"], - {"unsubscribed": 0, "email_group": email_group.email_group}): - recipients_list.append(d.email) - return list(set(recipients_list)) + status_count = frappe.get_all("Email Queue", + filters={"reference_doctype": self.doctype, "reference_name": self.name}, + fields=["status", "count(name)"], + group_by="status", + order_by="status", + as_list=True, + ) + self.get("__onload").status_count = dict(status_count) def validate_send(self): - if self.get("__islocal"): - throw(_("Please save the Newsletter before sending")) + """Validate if Newsletter can be sent. + """ + self.validate_newsletter_status() + self.validate_newsletter_recipients() - if not self.recipients: - frappe.throw(_("Newsletter should have at least one recipient")) + def validate_newsletter_status(self): + if self.email_sent: + frappe.throw(_("Newsletter has already been sent"), exc=NewsletterAlreadySentError) + + if self.get("__islocal"): + frappe.throw(_("Please save the Newsletter before sending"), exc=NewsletterNotSavedError) + + def validate_newsletter_recipients(self): + if not self.newsletter_recipients: + frappe.throw(_("Newsletter should have atleast one recipient"), exc=NoRecipientFoundError) + self.validate_recipient_address() + + def validate_sender_address(self): + """Validate self.send_from is a valid email address or not. + """ + if self.send_from: + frappe.utils.validate_email_address(self.send_from, throw=True) + + def validate_recipient_address(self): + """Validate if self.newsletter_recipients are all valid email addresses or not. + """ + for recipient in self.newsletter_recipients: + frappe.utils.validate_email_address(recipient, throw=True) + + def get_linked_email_queue(self) -> List[str]: + """Get list of email queue linked to this newsletter. + """ + return frappe.get_all("Email Queue", + filters={ + "reference_doctype": self.doctype, + "reference_name": self.name, + }, + pluck="name", + ) + + def get_success_recipients(self) -> List[str]: + """Recipients who have already recieved the newsletter. + + Couldn't think of a better name ;) + """ + return frappe.get_all("Email Queue Recipient", + filters={ + "status": ("in", ["Not Sent", "Sending", "Sent"]), + "parentfield": ("in", self.get_linked_email_queue()), + }, + pluck="recipient", + ) + + def get_pending_recipients(self) -> List[str]: + """Get list of pending recipients of the newsletter. These + recipients may not have receive the newsletter in the previous iteration. + """ + return [ + x for x in self.newsletter_recipients if x not in self.get_success_recipients() + ] + + def queue_all(self, test_emails: List[str] = None): + """Queue Newsletter to all the recipients generated from the `Email Group` + table + + Args: + test_email (List[str], optional): Send test Newsletter to the passed set of emails. + Defaults to None. + """ + if test_emails: + for test_email in test_emails: + frappe.utils.validate_email_address(test_email, throw=True) + else: + self.validate() + self.validate_send() + + newsletter_recipients = test_emails or self.get_pending_recipients() + self.send_newsletter(emails=newsletter_recipients) + + if not test_emails: + self.email_sent = True + self.schedule_send = frappe.utils.now_datetime() + self.scheduled_to_send = len(newsletter_recipients) + self.save() + + def get_newsletter_attachments(self) -> List[Dict[str, str]]: + """Get list of attachments on current Newsletter + """ + attachments = [] + + if self.send_attachments: + files = frappe.get_all( + "File", + filters={"attached_to_doctype": "Newsletter", "attached_to_name": self.name}, + order_by="creation desc", + pluck="name", + ) + attachments.extend({"fid": file} for file in files) + + return attachments + + def send_newsletter(self, emails: List[str]): + """Trigger email generation for `emails` and add it in Email Queue. + """ + # TODO: get rid of this maybe? + message = self.get_message() + attachments = self.get_newsletter_attachments() + sender = self.send_from or frappe.utils.get_formatted_email(self.owner) + args = {"message": message, "name": self.name} + + frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test + + frappe.sendmail( + subject=self.subject, + sender=sender, + recipients=emails, + message=message, + attachments=attachments, + template="newsletter", + add_unsubscribe_link=self.send_unsubscribe_link, + unsubscribe_method="/unsubscribe", + unsubscribe_params={"name": self.name}, + reference_doctype=self.doctype, + reference_name=self.name, + queue_separately=True, + send_priority=0, + args=args, + ) + + frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test + + def get_message(self) -> str: + if self.content_type == "HTML": + return frappe.render_template(self.message_html, {"doc": self.as_dict()}) + if self.content_type == "Markdown": + return frappe.utils.markdown(self.message_md) + # fallback to Rich Text + return self.message + + def get_recipients(self) -> List[str]: + """Get recipients from Email Group""" + emails = frappe.get_all( + "Email Group Member", + filters={"unsubscribed": 0, "email_group": ("in", self.get_email_groups())}, + pluck="email", + ) + return list(set(emails)) + + def get_email_groups(self) -> List[str]: + # wondering why the 'or'? i can't figure out why both aren't equivalent - @gavin + return [ + x.email_group for x in self.email_group + ] or frappe.get_all( + "Newsletter Email Group", + filters={"parent": self.name, "parenttype": "Newsletter"}, + pluck="email_group", + ) + + def get_attachments(self) -> List[Dict[str, str]]: + return frappe.get_all( + "File", + fields=["name", "file_name", "file_url", "is_private"], + filters={ + "attached_to_name": self.name, + "attached_to_doctype": "Newsletter", + "is_private": 0, + }, + ) def get_context(self, context): newsletters = get_newsletter_list("Newsletter", None, None, 0) if newsletters: newsletter_list = [d.name for d in newsletters] if self.name not in newsletter_list: - frappe.redirect_to_message(_('Permission Error'), - _("You are not permitted to view the newsletter.")) + frappe.redirect_to_message( + _("Permission Error"), _("You are not permitted to view the newsletter.") + ) frappe.local.flags.redirect_location = frappe.local.response.location raise frappe.Redirect else: - context.attachments = get_attachments(self.name) + context.attachments = self.get_attachments() context.no_cache = 1 context.show_sidebar = True -def get_attachments(name): - return frappe.get_all("File", - fields=["name", "file_name", "file_url", "is_private"], - filters = {"attached_to_name": name, "attached_to_doctype": "Newsletter", "is_private":0}) - - -def get_email_groups(name): - return frappe.db.get_all("Newsletter Email Group", ["email_group"],{"parent":name, "parenttype":"Newsletter"}) - - @frappe.whitelist(allow_guest=True) def confirmed_unsubscribe(email, group): """ unsubscribe the email(user) from the mailing list(email_group) """ - frappe.flags.ignore_permissions=True + frappe.flags.ignore_permissions = True doc = frappe.get_doc("Email Group Member", {"email": email, "email_group": group}) if not doc.unsubscribed: doc.unsubscribed = 1 - doc.save(ignore_permissions = True) - -def create_lead(email_id): - """create a lead if it does not exist""" - from frappe.model.naming import get_default_naming_series - full_name, email_id = parse_addr(email_id) - if frappe.db.get_value("Lead", {"email_id": email_id}): - return - - lead = frappe.get_doc({ - "doctype": "Lead", - "email_id": email_id, - "lead_name": full_name or email_id, - "status": "Lead", - "naming_series": get_default_naming_series("Lead"), - "company": frappe.db.get_default("Company"), - "source": "Email" - }) - lead.insert() + doc.save(ignore_permissions=True) @frappe.whitelist(allow_guest=True) -def subscribe(email, email_group=_('Website')): - url = frappe.utils.get_url("/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription") +\ - "?" + get_signed_params({"email": email, "email_group": email_group}) +def subscribe(email, email_group=_("Website")): + """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email. + """ - email_template = frappe.db.get_value('Email Group', email_group, ['confirmation_email_template']) + # build subscription confirmation URL + api_endpoint = frappe.utils.get_url( + "/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription" + ) + signed_params = get_signed_params({"email": email, "email_group": email_group}) + confirm_subscription_url = f"{api_endpoint}?{signed_params}" - content='' - if email_template: - args = dict( - email=email, - confirmation_url=url, - email_group=email_group - ) + # fetch custom template if available + email_confirmation_template = frappe.db.get_value( + "Email Group", email_group, "confirmation_email_template" + ) - email_template = frappe.get_doc("Email Template", email_template) + # build email and send + if email_confirmation_template: + args = {"email": email, "confirmation_url": confirm_subscription_url, "email_group": email_group} + email_template = frappe.get_doc("Email Template", email_confirmation_template) + email_subject = email_template.subject content = frappe.render_template(email_template.response, args) - - if not content: - messages = ( + else: + email_subject = _("Confirm Your Email") + translatable_content = ( _("Thank you for your interest in subscribing to our updates"), _("Please verify your Email Address"), - url, - _("Click here to verify") + confirm_subscription_url, + _("Click here to verify"), ) - content = """ -

{0}. {1}.

-

{3}

- """.format(*messages) +

{0}. {1}.

+

{3}

+ """.format(*translatable_content) + + frappe.sendmail( + email, + subject=email_subject, + content=content, + now=True, + ) - frappe.sendmail(email, subject=getattr('email_template', 'subject', '') or _("Confirm Your Email"), content=content, now=True) @frappe.whitelist(allow_guest=True) -def confirm_subscription(email, email_group=_('Website')): +def confirm_subscription(email, email_group=_("Website")): + """API endpoint to confirm email subscription. + This endpoint is called when user clicks on the link sent to their mail. + """ if not verify_request(): return if not frappe.db.exists("Email Group", email_group): - frappe.get_doc({ - "doctype": "Email Group", - "title": email_group - }).insert(ignore_permissions=True) + frappe.get_doc({"doctype": "Email Group", "title": email_group}).insert( + ignore_permissions=True + ) frappe.flags.ignore_permissions = True add_subscribers(email_group, email) frappe.db.commit() - frappe.respond_as_web_page(_("Confirmed"), + frappe.respond_as_web_page( + _("Confirmed"), _("{0} has been successfully added to the Email Group.").format(email), - indicator_color='green') - - -def send_newsletter(newsletter): - try: - doc = frappe.get_doc("Newsletter", newsletter) - doc.queue_all() - - except: - frappe.db.rollback() - - # wasn't able to send emails :( - doc.db_set("email_sent", 0) - frappe.db.commit() - - frappe.log_error(title='Send Newsletter') - - raise - - else: - frappe.db.commit() + indicator_color="green", + ) def get_list_context(context=None): @@ -268,12 +349,35 @@ def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20 '''.format(','.join(['%s'] * len(email_group_list)), limit_page_length, limit_start), email_group_list, as_dict=1) + def send_scheduled_email(): """Send scheduled newsletter to the recipients.""" - scheduled_newsletter = frappe.get_all('Newsletter', filters = { - 'schedule_send': ('<=', now_datetime()), - 'email_sent': 0, - 'schedule_sending': 1 - }, fields = ['name'], ignore_ifnull=True) + scheduled_newsletter = frappe.get_all( + "Newsletter", + filters={ + "schedule_send": ("<=", frappe.utils.now_datetime()), + "email_sent": False, + "schedule_sending": True, + }, + ignore_ifnull=True, + pluck="name", + ) + for newsletter in scheduled_newsletter: - send_newsletter(newsletter.name) + try: + frappe.get_doc("Newsletter", newsletter).queue_all() + + except Exception: + frappe.db.rollback() + + # wasn't able to send emails :( + frappe.db.set_value("Newsletter", newsletter, "email_sent", 0) + message = ( + f"Newsletter {newsletter} failed to send" + "\n\n" + f"Traceback: {frappe.get_traceback()}" + ) + frappe.log_error(title="Send Newsletter", message=message) + + if not frappe.flags.in_test: + frappe.db.commit() diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 3abd339ed9..abbcc6440c 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -1,17 +1,26 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + import unittest from random import choice +from typing import Union +from unittest.mock import MagicMock, PropertyMock, patch import frappe -from frappe.email.doctype.newsletter.newsletter import ( - confirmed_unsubscribe, - send_scheduled_email, +from frappe.desk.form.load import run_onload +from frappe.email.doctype.newsletter.exceptions import ( + NewsletterAlreadySentError, NoRecipientFoundError +) +from frappe.email.doctype.newsletter.newsletter import ( + Newsletter, + confirmed_unsubscribe, + get_newsletter_list, + send_scheduled_email ) -from frappe.email.doctype.newsletter.newsletter import get_newsletter_list from frappe.email.queue import flush from frappe.utils import add_days, getdate + test_dependencies = ["Email Group"] emails = [ "test_subscriber1@example.com", @@ -19,23 +28,107 @@ emails = [ "test_subscriber3@example.com", "test1@example.com", ] +newsletters = [] -class TestNewsletter(unittest.TestCase): +def get_dotted_path(obj: type) -> str: + klass = obj.__class__ + module = klass.__module__ + if module == 'builtins': + return klass.__qualname__ # avoid outputs like 'builtins.str' + return f"{module}.{klass.__qualname__}" + + +class TestNewsletterMixin: def setUp(self): frappe.set_user("Administrator") - frappe.db.sql("delete from `tabEmail Group Member`") + self.setup_email_group() + def tearDown(self): + frappe.set_user("Administrator") + for newsletter in newsletters: + frappe.db.delete("Email Queue", { + "reference_doctype": "Newsletter", + "reference_name": newsletter, + }) + frappe.delete_doc("Newsletter", newsletter) + frappe.db.delete("Newsletter Email Group", newsletter) + newsletters.remove(newsletter) + + def setup_email_group(self): if not frappe.db.exists("Email Group", "_Test Email Group"): - frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert() - - for email in emails: frappe.get_doc({ - "doctype": "Email Group Member", - "email": email, - "email_group": "_Test Email Group" + "doctype": "Email Group", + "title": "_Test Email Group" }).insert() + for email in emails: + doctype = "Email Group Member" + email_filters = { + "email": email, + "email_group": "_Test Email Group" + } + try: + frappe.get_doc({ + "doctype": doctype, + **email_filters, + }).insert() + except Exception: + frappe.db.update(doctype, email_filters, "unsubscribed", 0) + + def send_newsletter(self, published=0, schedule_send=None) -> Union[str, None]: + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") + frappe.db.delete("Newsletter") + + newsletter_options = { + "published": published, + "schedule_sending": bool(schedule_send), + "schedule_send": schedule_send + } + newsletter = self.get_newsletter(**newsletter_options) + + if schedule_send: + send_scheduled_email() + else: + newsletter.send_emails() + return newsletter.name + + @staticmethod + def get_newsletter(**kwargs) -> "Newsletter": + """Generate and return Newsletter object + """ + doctype = "Newsletter" + newsletter_content = { + "subject": "_Test Newsletter", + "send_from": "Test Sender ", + "content_type": "Rich Text", + "message": "Testing my news.", + } + similar_newsletters = frappe.db.get_all(doctype, newsletter_content, pluck="name") + + for similar_newsletter in similar_newsletters: + frappe.delete_doc(doctype, similar_newsletter) + + newsletter = frappe.get_doc({"doctype": doctype, **newsletter_content, **kwargs}) + newsletter.append("email_group", {"email_group": "_Test Email Group"}) + newsletter.save(ignore_permissions=True) + newsletter.reload() + newsletters.append(newsletter.name) + + attached_files = frappe.get_all("File", { + "attached_to_doctype": newsletter.doctype, + "attached_to_name": newsletter.name, + }, + pluck="name", + ) + for file in attached_files: + frappe.delete_doc("File", file) + + return newsletter + + +class TestNewsletter(TestNewsletterMixin, unittest.TestCase): def test_send(self): self.send_newsletter() @@ -64,40 +157,15 @@ class TestNewsletter(unittest.TestCase): if email != to_unsubscribe: self.assertTrue(email in recipients) - @staticmethod - def send_newsletter(published=0, schedule_send=None): - frappe.db.sql("delete from `tabEmail Queue`") - frappe.db.sql("delete from `tabEmail Queue Recipient`") - frappe.db.sql("delete from `tabNewsletter`") - newsletter = frappe.get_doc({ - "doctype": "Newsletter", - "subject": "_Test Newsletter", - "send_from": "Test Sender ", - "content_type": "Rich Text", - "message": "Testing my news.", - "published": published, - "schedule_sending": bool(schedule_send), - "schedule_send": schedule_send - }).insert(ignore_permissions=True) - - newsletter.append("email_group", {"email_group": "_Test Email Group"}) - newsletter.save() - if schedule_send: - send_scheduled_email() - return - - newsletter.send_emails() - return newsletter.name - def test_portal(self): - self.send_newsletter(1) + self.send_newsletter(published=1) frappe.set_user("test1@example.com") - newsletters = get_newsletter_list("Newsletter", None, None, 0) - self.assertEqual(len(newsletters), 1) + newsletter_list = get_newsletter_list("Newsletter", None, None, 0) + self.assertEqual(len(newsletter_list), 1) def test_newsletter_context(self): context = frappe._dict() - newsletter_name = self.send_newsletter(1) + newsletter_name = self.send_newsletter(published=1) frappe.set_user("test2@example.com") doc = frappe.get_doc("Newsletter", newsletter_name) doc.get_context(context) @@ -112,3 +180,68 @@ class TestNewsletter(unittest.TestCase): recipients = [e.recipients[0].recipient for e in email_queue_list] for email in emails: self.assertTrue(email in recipients) + + def test_newsletter_test_send(self): + """Test "Test Send" functionality of Newsletter + """ + newsletter = self.get_newsletter() + newsletter.test_email_id = choice(emails) + newsletter.test_send() + + self.assertFalse(newsletter.email_sent) + newsletter.save = MagicMock() + self.assertFalse(newsletter.save.called) + + def test_newsletter_status(self): + """Test for Newsletter's stats on onload event + """ + newsletter = self.get_newsletter() + newsletter.email_sent = True + # had to use run_onload as calling .onload directly bought weird errors + # like TestNewsletter has no attribute "_TestNewsletter__onload" + run_onload(newsletter) + self.assertIsInstance(newsletter.get("__onload").status_count, dict) + + def test_already_sent_newsletter(self): + newsletter = self.get_newsletter() + newsletter.send_emails() + + with self.assertRaises(NewsletterAlreadySentError): + newsletter.send_emails() + + def test_newsletter_with_no_recipient(self): + newsletter = self.get_newsletter() + property_path = f"{get_dotted_path(newsletter)}.newsletter_recipients" + + with patch(property_path, new_callable=PropertyMock) as mock_newsletter_recipients: + mock_newsletter_recipients.return_value = [] + with self.assertRaises(NoRecipientFoundError): + newsletter.send_emails() + + def test_send_newsletter_with_attachments(self): + newsletter = self.get_newsletter() + newsletter.reload() + file_attachment = frappe.get_doc({ + "doctype": "File", + "file_name": "test1.txt", + "attached_to_doctype": newsletter.doctype, + "attached_to_name": newsletter.name, + "content": frappe.mock("paragraph") + }) + file_attachment.save() + newsletter.send_attachments = True + newsletter_attachments = newsletter.get_newsletter_attachments() + self.assertEqual(len(newsletter_attachments), 1) + self.assertEqual(newsletter_attachments[0]["fid"], file_attachment.name) + + def test_send_scheduled_email_error_handling(self): + newsletter = self.get_newsletter(schedule_send=add_days(getdate(), -1)) + job_path = "frappe.email.doctype.newsletter.newsletter.Newsletter.queue_all" + m = MagicMock(side_effect=frappe.OutgoingEmailError) + + with self.assertRaises(frappe.OutgoingEmailError): + with patch(job_path, new_callable=m): + send_scheduled_email() + + newsletter.reload() + self.assertEqual(newsletter.email_sent, 0) diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json index db96304207..4167858db2 100644 --- a/frappe/integrations/workspace/integrations/integrations.json +++ b/frappe/integrations/workspace/integrations/integrations.json @@ -1,22 +1,27 @@ { - "category": "Administration", + "category": "", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Backup\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Google Services\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Authentication\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Payments\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Settings\", \"col\": 4}}]", "creation": "2020-03-02 15:16:18.714190", "developer_mode_only": 0, - "disable_user_customization": 1, + "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", + "extends": "", "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "integration", "idx": 0, - "is_standard": 1, + "is_default": 0, + "is_standard": 0, "label": "Integrations", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Backup", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -25,6 +30,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dropbox Settings", + "link_count": 0, "link_to": "Dropbox Settings", "link_type": "DocType", "onboard": 0, @@ -35,6 +41,7 @@ "hidden": 0, "is_query_report": 0, "label": "S3 Backup Settings", + "link_count": 0, "link_to": "S3 Backup Settings", "link_type": "DocType", "onboard": 0, @@ -45,6 +52,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Drive", + "link_count": 0, "link_to": "Google Drive", "link_type": "DocType", "onboard": 0, @@ -54,6 +62,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Services", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -62,6 +71,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Settings", + "link_count": 0, "link_to": "Google Settings", "link_type": "DocType", "onboard": 0, @@ -72,6 +82,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Contacts", + "link_count": 0, "link_to": "Google Contacts", "link_type": "DocType", "onboard": 0, @@ -82,6 +93,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Calendar", + "link_count": 0, "link_to": "Google Calendar", "link_type": "DocType", "onboard": 0, @@ -92,6 +104,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Drive", + "link_count": 0, "link_to": "Google Drive", "link_type": "DocType", "onboard": 0, @@ -101,6 +114,7 @@ "hidden": 0, "is_query_report": 0, "label": "Authentication", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -109,6 +123,7 @@ "hidden": 0, "is_query_report": 0, "label": "Social Login Key", + "link_count": 0, "link_to": "Social Login Key", "link_type": "DocType", "onboard": 0, @@ -119,6 +134,7 @@ "hidden": 0, "is_query_report": 0, "label": "LDAP Settings", + "link_count": 0, "link_to": "LDAP Settings", "link_type": "DocType", "onboard": 0, @@ -129,6 +145,7 @@ "hidden": 0, "is_query_report": 0, "label": "OAuth Client", + "link_count": 0, "link_to": "OAuth Client", "link_type": "DocType", "onboard": 0, @@ -139,6 +156,7 @@ "hidden": 0, "is_query_report": 0, "label": "OAuth Provider Settings", + "link_count": 0, "link_to": "OAuth Provider Settings", "link_type": "DocType", "onboard": 0, @@ -148,6 +166,7 @@ "hidden": 0, "is_query_report": 0, "label": "Payments", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -156,6 +175,7 @@ "hidden": 0, "is_query_report": 0, "label": "Braintree Settings", + "link_count": 0, "link_to": "Braintree Settings", "link_type": "DocType", "onboard": 0, @@ -166,6 +186,7 @@ "hidden": 0, "is_query_report": 0, "label": "PayPal Settings", + "link_count": 0, "link_to": "PayPal Settings", "link_type": "DocType", "onboard": 0, @@ -176,6 +197,7 @@ "hidden": 0, "is_query_report": 0, "label": "Razorpay Settings", + "link_count": 0, "link_to": "Razorpay Settings", "link_type": "DocType", "onboard": 0, @@ -186,6 +208,7 @@ "hidden": 0, "is_query_report": 0, "label": "Stripe Settings", + "link_count": 0, "link_to": "Stripe Settings", "link_type": "DocType", "onboard": 0, @@ -196,6 +219,7 @@ "hidden": 0, "is_query_report": 0, "label": "Paytm Settings", + "link_count": 0, "link_to": "Paytm Settings", "link_type": "DocType", "onboard": 0, @@ -205,6 +229,7 @@ "hidden": 0, "is_query_report": 0, "label": "Settings", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -213,6 +238,7 @@ "hidden": 0, "is_query_report": 0, "label": "Webhook", + "link_count": 0, "link_to": "Webhook", "link_type": "DocType", "onboard": 0, @@ -223,38 +249,37 @@ "hidden": 0, "is_query_report": 0, "label": "Slack Webhook URL", + "link_count": 0, "link_to": "Slack Webhook URL", "link_type": "DocType", "onboard": 0, "type": "Link" }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Twilio Settings", - "link_to": "Twilio Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "dependencies": "", "hidden": 0, "is_query_report": 0, "label": "SMS Settings", + "link_count": 0, "link_to": "SMS Settings", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2020-12-01 13:38:39.706680", + "modified": "2021-08-05 12:16:00.355267", "modified_by": "Administrator", "module": "Integrations", "name": "Integrations", + "onboarding": "", "owner": "Administrator", + "parent_page": "", "pin_to_bottom": 0, "pin_to_top": 0, - "shortcuts": [] + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 15, + "shortcuts": [], + "title": "Integrations" } \ No newline at end of file diff --git a/frappe/patches.txt b/frappe/patches.txt index 989b13e049..87919b0247 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -182,3 +182,4 @@ frappe.patches.v13_0.jinja_hook frappe.patches.v13_0.update_notification_channel_if_empty frappe.patches.v14_0.drop_data_import_legacy frappe.patches.v14_0.rename_cancelled_documents +frappe.patches.v14_0.update_workspace2 diff --git a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py index 79bacef5a2..5aaadd00e8 100644 --- a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py +++ b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py @@ -7,16 +7,14 @@ def execute(): ToDo = frappe.qb.DocType("ToDo") assignees = GroupConcat("owner").distinct().as_("assignees") - query = ( + assignments = ( frappe.qb.from_(ToDo) .select(ToDo.name, ToDo.reference_type, assignees) .where(Coalesce(ToDo.reference_type, "") != "") .where(Coalesce(ToDo.reference_name, "") != "") .where(ToDo.status != "Cancelled") .groupby(ToDo.reference_type, ToDo.reference_name) - ) - - assignments = frappe.db.sql(query, as_dict=True) + ).run(as_dict=True) for doc in assignments: assignments = doc.assignees.split(",") diff --git a/frappe/patches/v14_0/rename_cancelled_documents.py b/frappe/patches/v14_0/rename_cancelled_documents.py index fbe49c2351..4b565d4f76 100644 --- a/frappe/patches/v14_0/rename_cancelled_documents.py +++ b/frappe/patches/v14_0/rename_cancelled_documents.py @@ -129,9 +129,9 @@ def update_linked_doctypes(doctype, cancelled_doc_names): update `tab{linked_dt}` set - {column}=CONCAT({column}, '-CANC') + `{column}`=CONCAT(`{column}`, '-CANC') where - {column} in %(cancelled_doc_names)s; + `{column}` in %(cancelled_doc_names)s; """.format(linked_dt=linked_dt, column=field), {'cancelled_doc_names': cancelled_doc_names}) else: @@ -151,9 +151,9 @@ def update_dynamic_linked_doctypes(doctype, cancelled_doc_names): update `tab{linked_dt}` set - {column}=CONCAT({column}, '-CANC') + `{column}`=CONCAT(`{column}`, '-CANC') where - {column} in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s; + `{column}` in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s; """.format(linked_dt=linked_dt, column=fieldname, doctype_fieldname=doctype_fieldname), {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) else: diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py new file mode 100644 index 0000000000..2d7eb4cc76 --- /dev/null +++ b/frappe/patches/v14_0/update_workspace2.py @@ -0,0 +1,69 @@ +import frappe +import json +from frappe import _ + +def execute(): + frappe.reload_doc('desk', 'doctype', 'workspace', force=True) + order_by = "pin_to_top desc, pin_to_bottom asc, name asc" + for seq, wspace in enumerate(frappe.get_all('Workspace', order_by=order_by)): + doc = frappe.get_doc('Workspace', wspace.name) + content = create_content(doc) + update_wspace(doc, seq, content) + frappe.db.commit() + +def create_content(doc): + content = [] + if doc.onboarding: + content.append({"type":"onboarding","data":{"onboarding_name":doc.onboarding,"col":12}}) + if doc.charts: + invalid_links = [] + for c in doc.charts: + if c.get_invalid_links()[0]: + invalid_links.append(c) + else: + content.append({"type":"chart","data":{"chart_name":c.label,"col":12}}) + for l in invalid_links: + del doc.charts[doc.charts.index(l)] + if doc.shortcuts: + invalid_links = [] + if doc.charts: + content.append({"type":"spacer","data":{"col":12}}) + content.append({"type":"header","data":{"text":doc.shortcuts_label or _("Your Shortcuts"),"level":4,"col":12}}) + for s in doc.shortcuts: + if s.get_invalid_links()[0]: + invalid_links.append(s) + else: + content.append({"type":"shortcut","data":{"shortcut_name":s.label,"col":4}}) + for l in invalid_links: + del doc.shortcuts[doc.shortcuts.index(l)] + if doc.links: + invalid_links = [] + content.append({"type":"spacer","data":{"col":12}}) + content.append({"type":"header","data":{"text":doc.cards_label or _("Reports & Masters"),"level":4,"col":12}}) + for l in doc.links: + if l.type == 'Card Break': + content.append({"type":"card","data":{"card_name":l.label,"col":4}}) + if l.get_invalid_links()[0]: + invalid_links.append(l) + for l in invalid_links: + del doc.links[doc.links.index(l)] + return content + +def update_wspace(doc, seq, content): + if not doc.is_standard and not doc.public: + doc.sequence_id = seq + 1 + doc.content = json.dumps(content) + doc.public = 0 + doc.title = doc.extends + doc.extends = '' + doc.category = '' + doc.onboarding = '' + doc.extends_another_page = 0 + doc.is_default = 0 + doc.is_standard = 0 + doc.developer_mode_only = 0 + doc.disable_user_customization = 0 + doc.pin_to_top = 0 + doc.pin_to_bottom = 0 + doc.hide_custom = 0 + doc.save(ignore_permissions=True) \ No newline at end of file diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index 0e8e24b768..f216374526 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -57,6 +57,9 @@ + + + diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index c4ecf67c4f..294ac013fb 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -82,6 +82,7 @@ import "./frappe/ui/toolbar/toolbar.js"; import "./frappe/views/communication.js"; import "./frappe/views/translation_manager.js"; import "./frappe/views/workspace/workspace.js"; +import "./frappe/views/workspace/blocks/index.js"; import "./frappe/widgets/widget_group.js"; diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 9d106f46f4..810b6a404a 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -283,7 +283,7 @@ frappe.Application = class Application { frappe.workspaces = {}; for (let page of frappe.boot.allowed_workspaces || []) { frappe.modules[page.module]=page; - frappe.workspaces[frappe.router.slug(page.name)] = page; + frappe.workspaces[frappe.router.slug(page.title)] = page; } if (!frappe.workspaces['home']) { // default workspace is settings for Frappe diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 3bbc883b0c..fd49df027c 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1145,7 +1145,7 @@ frappe.ui.form.Form = class FrappeForm { // Add actions as menu item in Mobile View let menu_item_label = group ? `${group} > ${label}` : label; let menu_item = this.page.add_menu_item(menu_item_label, fn, false); - menu_item.parent().addClass("hidden-lg"); + menu_item.parent().addClass("hidden-xl"); this.custom_buttons[label] = btn; } diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index cad32954e9..8de1600c05 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -37,6 +37,8 @@ export default class Grid { } this.is_grid = true; + this.debounced_refresh = this.refresh.bind(this); + this.debounced_refresh = frappe.utils.debounce(this.debounced_refresh, 500); } allow_on_grid_editing() { @@ -500,7 +502,7 @@ export default class Grid { this.set_editable_grid_column_disp(fieldname, show); } - this.refresh(true); + this.debounced_refresh(); } set_editable_grid_column_disp(fieldname, show) { @@ -544,17 +546,17 @@ export default class Grid { toggle_reqd(fieldname, reqd) { this.get_docfield(fieldname).reqd = reqd; - this.refresh(); + this.debounced_refresh(); } toggle_enable(fieldname, enable) { this.get_docfield(fieldname).read_only = enable ? 0 : 1; - this.refresh(); + this.debounced_refresh(); } toggle_display(fieldname, show) { this.get_docfield(fieldname).hidden = show ? 0 : 1; - this.refresh(); + this.debounced_refresh(); } toggle_checkboxes(enable) { @@ -675,6 +677,7 @@ export default class Grid { if (!idx) { idx = this.grid_rows.length - 1; } + setTimeout(() => { this.grid_rows[idx].row .find('input[type="Text"],textarea,select').filter(':visible:first').focus(); @@ -934,6 +937,6 @@ export default class Grid { // update the parent too (for new rows) this.docfields.find(d => d.fieldname === fieldname)[property] = value; - this.refresh(); + this.debounced_refresh(); } } diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index d0f93882fb..4360f3e887 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -118,6 +118,7 @@ frappe.router = { convert_to_standard_route(route) { // /app/settings = ["Workspaces", "Settings"] + // /app/private/settings = ["Workspaces", "private", "Settings"] // /app/user = ["List", "User"] // /app/user/view/report = ["List", "User", "Report"] // /app/user/view/tree = ["Tree", "User"] @@ -126,8 +127,11 @@ frappe.router = { // /app/event/view/calendar/default = ["List", "Event", "Calendar", "Default"] if (frappe.workspaces[route[0]]) { - // workspace - route = ['Workspaces', frappe.workspaces[route[0]].name]; + // public workspace + route = ['Workspaces', frappe.workspaces[route[0]].title]; + } else if (frappe.workspaces[route[1]]) { + // private workspace + route = ['Workspaces', 'private', frappe.workspaces[route[1]].title]; } else if (this.routes[route[0]]) { // route route = this.set_doctype_route(route); @@ -136,6 +140,11 @@ frappe.router = { return route; }, + doctype_route_exist(route) { + route = this.get_sub_path_string(route).split('/'); + return this.routes[route[0]]; + }, + set_doctype_route(route) { let doctype_route = this.routes[route[0]]; // doctype route diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 65635ec1dd..21841296dc 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1325,6 +1325,19 @@ Object.assign(frappe.utils, { return clipboard_data.getData('Text'); }, + add_custom_button(html, action, class_name = "", title="", btn_type, wrapper, prepend) { + if (!btn_type) btn_type = 'btn-secondary'; + let button = $( + `` + ); + button.click(event => { + event.stopPropagation(); + action && action(event); + }); + !prepend && button.appendTo(wrapper); + prepend && wrapper.prepend(button); + }, + sleep(time) { return new Promise((resolve) => setTimeout(resolve, time)); } diff --git a/frappe/public/js/frappe/views/workspace/blocks/block.js b/frappe/public/js/frappe/views/workspace/blocks/block.js new file mode 100644 index 0000000000..aed3c2f727 --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/block.js @@ -0,0 +1,115 @@ +import get_dialog_constructor from "../../../widgets/widget_dialog.js"; + +export default class Block { + constructor(opts) { + Object.assign(this, opts); + } + + make(block, block_name, widget_type = block) { + let block_data = this.config.page_data[block+'s'].items.find(obj => { + return obj.label == block_name; + }); + if (!block_data) return false; + this.wrapper.innerHTML = ''; + block_data.in_customize_mode = !this.readOnly; + this.block_widget = new frappe.widget.SingleWidgetGroup({ + container: this.wrapper, + type: widget_type, + class_name: block == 'chart' ? 'widget-charts' : '', + options: this.options, + widgets: block_data, + api: this.api, + block: this.block + }); + this.wrapper.setAttribute(block+'_name', block_name); + if (!this.readOnly) { + this.block_widget.customize(); + } + return true; + } + + rendered() { + var e = this.wrapper.closest('.ce-block'); + e.classList.add("col-" + this.get_col()); + } + + new(block, widget_type = block) { + const dialog_class = get_dialog_constructor(widget_type); + let block_name = block+'_name'; + this.dialog = new dialog_class({ + label: this.label, + type: widget_type, + primary_action: (widget) => { + widget.in_customize_mode = 1; + this.block_widget = frappe.widget.make_widget({ + ...widget, + widget_type: widget_type, + container: this.wrapper, + options: { + ...this.options, + on_delete: () => this.api.blocks.delete(), + on_edit: () => this.on_edit(this.block_widget) + } + }); + this.block_widget.customize(this.options); + this.wrapper.setAttribute(block_name, this.block_widget.label); + this.new_block_widget = this.block_widget.get_config(); + this.add_tune_button(); + }, + }); + + if (!this.readOnly && this.data && !this.data[block_name]) { + this.dialog.make(); + } + } + + on_edit(block_obj) { + let block_name = block_obj.edit_dialog.type+'_name'; + if (block_obj.edit_dialog.type == 'links') { + block_name = 'card_name'; + } + let block = block_obj.get_config(); + this.block_widget.widgets = block; + this.wrapper.setAttribute(block_name, block.label); + this.new_block_widget = block_obj.get_config(); + } + + add_tune_button() { + let $widget_control = $(this.wrapper).find('.widget-control'); + frappe.utils.add_custom_button( + frappe.utils.icon('dot-horizontal', 'xs'), + (event) => { + let evn = event; + !$('.ce-settings.ce-settings--opened').length && + setTimeout(() => { + this.api.toolbar.toggleBlockSettings(); + var position = $(evn.target).offset(); + $('.ce-settings.ce-settings--opened').offset({ + top: position.top + 25, + left: position.left - 77 + }); + }, 50); + }, + "tune-btn", + `${__('Tune')}`, + null, + $widget_control, + true + ); + } + + get_col() { + let col = this.col || 12; + let class_name = "col-12"; + let wrapper = this.wrapper.closest('.ce-block'); + const col_class = new RegExp(/\bcol-.+?\b/, "g"); + if (wrapper && wrapper.className.match(col_class)) { + wrapper.classList.forEach(function (cn) { + cn.match(col_class) && (class_name = cn); + }); + let parts = class_name.split("-"); + col = parseInt(parts[1]); + } + return col; + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/card.js b/frappe/public/js/frappe/views/workspace/blocks/card.js new file mode 100644 index 0000000000..15e27fed40 --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/card.js @@ -0,0 +1,59 @@ +import Block from "./block.js"; +export default class Card extends Block { + static get toolbox() { + return { + title: 'Card', + icon: '' + }; + } + + static get isReadOnlySupported() { + return true; + } + + constructor({ data, api, config, readOnly, block }) { + super({ data, api, config, readOnly, block }); + this.sections = {}; + 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, + }; + } + + render() { + this.wrapper = document.createElement('div'); + this.new('card', 'links'); + + if (this.data && this.data.card_name) { + let has_data = this.make('card', this.data.card_name, 'links'); + if (!has_data) return; + } + + if (!this.readOnly) { + this.add_tune_button(); + } + + return this.wrapper; + } + + validate(savedData) { + if (!savedData.card_name) { + return false; + } + + return true; + } + + save(blockContent) { + return { + card_name: blockContent.getAttribute('card_name'), + col: this.get_col(), + new: this.new_block_widget + }; + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/chart.js b/frappe/public/js/frappe/views/workspace/blocks/chart.js new file mode 100644 index 0000000000..e41063e6fc --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/chart.js @@ -0,0 +1,59 @@ +import Block from "./block.js"; +export default class Chart extends Block { + static get toolbox() { + return { + title: 'Chart', + icon: '' + }; + } + + 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 : "12"; + 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, + max_widget_count: 2, + }; + } + + render() { + this.wrapper = document.createElement('div'); + this.new('chart'); + + if (this.data && this.data.chart_name) { + let has_data = this.make('chart', this.data.chart_name); + if (!has_data) return; + } + + if (!this.readOnly) { + this.add_tune_button(); + } + + return this.wrapper; + } + + validate(savedData) { + if (!savedData.chart_name) { + return false; + } + + return true; + } + + save(blockContent) { + return { + chart_name: blockContent.getAttribute('chart_name'), + col: this.get_col(), + new: this.new_block_widget + }; + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/header.js b/frappe/public/js/frappe/views/workspace/blocks/header.js new file mode 100644 index 0000000000..356f9c3244 --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/header.js @@ -0,0 +1,339 @@ +import Block from "./block.js"; +export default class Header extends Block { + + constructor({ data, config, api, readOnly }) { + super({ config, api, readOnly }); + + this._CSS = { + block: this.api.styles.block, + settingsButton: this.api.styles.settingsButton, + settingsButtonActive: this.api.styles.settingsButtonActive, + wrapper: 'ce-header', + }; + + this._settings = this.config; + this._data = this.normalizeData(data); + this.settingsButtons = []; + this._element = this.getTag(); + + this.data = data; + this.col = this.data.col ? this.data.col : "12"; + } + + normalizeData(data) { + const newData = {}; + + if (typeof data !== 'object') { + data = {}; + } + + newData.text = data.text || ''; + newData.level = parseInt(data.level) || this.defaultLevel.number; + newData.col = parseInt(data.col) || 12; + + return newData; + } + + render() { + this.wrapper = document.createElement('div'); + this.wrapper.contentEditable = this.readOnly ? 'false' : 'true'; + if (!this.readOnly) { + let $widget_head = $(`
`); + let $widget_control = $(`
`); + + $widget_head[0].appendChild(this._element); + $widget_control.appendTo($widget_head); + $widget_head.appendTo(this.wrapper); + + this.wrapper.classList.add('widget', 'header'); + + frappe.utils.add_custom_button( + frappe.utils.icon('dot-horizontal', 'xs'), + (event) => { + let evn = event; + !$('.ce-settings.ce-settings--opened').length && + setTimeout(() => { + this.api.toolbar.toggleBlockSettings(); + var position = $(evn.target).offset(); + $('.ce-settings.ce-settings--opened').offset({ + top: position.top + 25, + left: position.left - 77 + }); + }, 50); + }, + "tune-btn", + `${__('Tune')}`, + null, + $widget_control + ); + + frappe.utils.add_custom_button( + frappe.utils.icon('drag', 'xs'), + null, + "drag-handle", + `${__('Drag')}`, + null, + $widget_control + ); + + frappe.utils.add_custom_button( + frappe.utils.icon('delete', 'xs'), + () => this.api.blocks.delete(), + "delete-header", + `${__('Delete')}`, + null, + $widget_control + ); + + return this.wrapper; + } + return this._element; + } + + renderSettings() { + const holder = document.createElement('DIV'); + + if (this.levels.length <= 1) { + return holder; + } + + this.levels.forEach(level => { + const selectTypeButton = document.createElement('SPAN'); + + selectTypeButton.classList.add(this._CSS.settingsButton); + + if (this.currentLevel.number === level.number) { + selectTypeButton.classList.add(this._CSS.settingsButtonActive); + } + + selectTypeButton.innerHTML = level.svg; + + selectTypeButton.dataset.level = level.number; + + selectTypeButton.addEventListener('click', () => { + this.setLevel(level.number); + }); + + holder.appendChild(selectTypeButton); + + this.settingsButtons.push(selectTypeButton); + }); + + return holder; + } + + setLevel(level) { + this.data = { + level: level, + text: this.data.text, + }; + + this.settingsButtons.forEach(button => { + button.classList.toggle(this._CSS.settingsButtonActive, parseInt(button.dataset.level) === level); + }); + } + + merge(data) { + const newData = { + text: this.data.text + data.text, + level: this.data.level, + }; + + this.data = newData; + } + + validate(blockData) { + return blockData.text.trim() !== ''; + } + + save(toolsContent) { + this.wrapper = this._element; + return { + text: toolsContent.innerText, + level: this.currentLevel.number, + col: this.get_col() + }; + } + + rendered() { + var e = this._element.closest('.ce-block'); + e.classList.add("col-" + this.get_col()); + } + + static get conversionConfig() { + return { + export: 'text', // use 'text' property for other blocks + import: 'text', // fill 'text' property from other block's export string + }; + } + + static get sanitize() { + return { + level: false, + text: {}, + }; + } + + static get isReadOnlySupported() { + return true; + } + + get data() { + this._data.text = this._element.innerHTML; + this._data.level = this.currentLevel.number; + + return this._data; + } + + set data(data) { + this._data = this.normalizeData(data); + + if (data.level !== undefined && this._element.parentNode) { + const newHeader = this.getTag(); + newHeader.innerHTML = this._element.innerHTML; + this._element.parentNode.replaceChild(newHeader, this._element); + this._element = newHeader; + } + + if (data.text !== undefined) { + this._element.innerHTML = this._data.text || ''; + } + + if (!this.readOnly && this.wrapper) { + this.wrapper.classList.add('widget', 'header'); + } + } + + getTag() { + const tag = document.createElement(this.currentLevel.tag); + + tag.innerHTML = this._data.text || ''; + + tag.classList.add(this._CSS.wrapper); + + if (!this.readOnly) { + tag.contentEditable = true; + } + + tag.dataset.placeholder = this.api.i18n.t(this._settings.placeholder || ''); + + return tag; + } + + get currentLevel() { + let level = this.levels.find(levelItem => levelItem.number === this._data.level); + + if (!level) { + level = this.defaultLevel; + } + + return level; + } + + get defaultLevel() { + if (this._settings.defaultLevel) { + const userSpecified = this.levels.find(levelItem => { + return levelItem.number === this._settings.defaultLevel; + }); + + if (userSpecified) { + return userSpecified; + } else { + // console.warn('(ง\'̀-\'́)ง Heading Tool: the default level specified was not found in available levels'); + } + } + + return this.levels[1]; + } + + get levels() { + const availableLevels = [ + { + number: 1, + tag: 'H1', + svg: '', + }, + { + number: 2, + tag: 'H2', + svg: '', + }, + { + number: 3, + tag: 'H3', + svg: '', + }, + { + number: 4, + tag: 'H4', + svg: '', + }, + { + number: 5, + tag: 'H5', + svg: '', + }, + { + number: 6, + tag: 'H6', + svg: '', + }, + ]; + + return this._settings.levels ? availableLevels.filter( + l => this._settings.levels.includes(l.number) + ) : availableLevels; + } + + onPaste(event) { + const content = event.detail.data; + + let level = this.defaultLevel.number; + + switch (content.tagName) { + case 'H1': + level = 1; + break; + case 'H2': + level = 2; + break; + case 'H3': + level = 3; + break; + case 'H4': + level = 4; + break; + case 'H5': + level = 5; + break; + case 'H6': + level = 6; + break; + } + + if (this._settings.levels) { + // Fallback to nearest level when specified not available + level = this._settings.levels.reduce((prevLevel, currLevel) => { + return Math.abs(currLevel - level) < Math.abs(prevLevel - level) ? currLevel : prevLevel; + }); + } + + this.data = { + level, + text: content.innerHTML, + }; + } + + static get pasteConfig() { + return { + tags: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'], + }; + } + + static get toolbox() { + return { + icon: '', + title: 'Heading', + }; + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/index.js b/frappe/public/js/frappe/views/workspace/blocks/index.js new file mode 100644 index 0000000000..00a9b8c83a --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/index.js @@ -0,0 +1,27 @@ +// import blocks +import Header from "./header"; +import Paragraph from "./paragraph"; +import Card from "./card"; +import Chart from "./chart"; +import Shortcut from "./shortcut"; +import Spacer from "./spacer"; +import Onboarding from "./onboarding"; + +// import tunes +import SpacingTune from "./spacing_tune"; + +frappe.provide("frappe.wspace_block"); + +frappe.wspace_block.blocks = { + header: Header, + paragraph: Paragraph, + card: Card, + chart: Chart, + shortcut: Shortcut, + spacer: Spacer, + onboarding: Onboarding, +}; + +frappe.wspace_block.tunes = { + spacing_tune: SpacingTune +}; \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/onboarding.js b/frappe/public/js/frappe/views/workspace/blocks/onboarding.js new file mode 100644 index 0000000000..7176b7726d --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/onboarding.js @@ -0,0 +1,129 @@ +import get_dialog_constructor from "../../../widgets/widget_dialog.js"; +import Block from "./block.js"; +export default class Onboarding extends Block { + static get toolbox() { + return { + title: 'Onboarding', + icon: '' + }; + } + + 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 : "12"; + 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 + }; + } + + rendered() { + var e = this.wrapper.closest('.ce-block'); + if (this.readOnly && !$(this.wrapper).find('.onboarding-widget-box').is(':visible')) { + $(e).hide(); + } + e.classList.add("col-" + this.get_col()); + } + + new(block, widget_type = block) { + const dialog_class = get_dialog_constructor(widget_type); + let block_name = block+'_name'; + this.dialog = new dialog_class({ + label: this.label, + type: widget_type, + primary_action: (widget) => { + widget.in_customize_mode = 1; + this.block_widget = frappe.widget.make_widget({ + ...widget, + widget_type: widget_type, + container: this.wrapper, + options: { + ...this.options, + on_delete: () => this.api.blocks.delete(), + on_edit: () => this.on_edit(this.block_widget) + }, + new: true + }); + this.block_widget.customize(this.options); + this.wrapper.setAttribute(block_name, this.block_widget.label || this.block_widget.onboarding_name); + this.new_block_widget = this.block_widget.get_config(); + this.add_tune_button(); + }, + }); + + if (!this.readOnly && this.data && !this.data[block_name]) { + this.dialog.make(); + } + } + + make(block, block_name) { + let block_data = this.config.page_data['onboardings'].items.find(obj => { + return obj.label == block_name; + }); + if (!block_data) return false; + this.wrapper.innerHTML = ''; + block_data.in_customize_mode = !this.readOnly; + this.block_widget = frappe.widget.make_widget({ + container: this.wrapper, + widget_type: 'onboarding', + in_customize_mode: block_data.in_customize_mode, + options: { + ...this.options, + on_delete: () => this.api.blocks.delete(), + on_edit: () => this.on_edit(this.block_widget) + }, + label: block_data.label, + title: block_data.title || __("Let's Get Started"), + subtitle: block_data.subtitle, + steps: block_data.items, + success: block_data.success, + docs_url: block_data.docs_url, + user_can_dismiss: block_data.user_can_dismiss, + }); + this.wrapper.setAttribute(block+'_name', block_name); + if (!this.readOnly) { + this.block_widget.customize(this.options); + } + return true; + } + + render() { + this.wrapper = document.createElement('div'); + this.new('onboarding'); + + if (this.data && this.data.onboarding_name) { + let has_data = this.make('onboarding', this.data.onboarding_name); + if (!has_data) return; + } + + if (!this.readOnly) { + this.add_tune_button(); + } + $(this.wrapper).css("padding-bottom", "20px"); + return this.wrapper; + } + + validate(savedData) { + if (!savedData.onboarding_name) { + return false; + } + + return true; + } + + save(blockContent) { + return { + onboarding_name: blockContent.getAttribute('onboarding_name'), + col: this.get_col(), + new: this.new_block_widget + }; + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/paragraph.js b/frappe/public/js/frappe/views/workspace/blocks/paragraph.js new file mode 100644 index 0000000000..26afa65d51 --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/paragraph.js @@ -0,0 +1,195 @@ +import Block from "./block.js"; +export default class Paragraph extends Block { + + static get DEFAULT_PLACEHOLDER() { + return ''; + } + + constructor({ data, config, api, readOnly }) { + super({ config, api, readOnly }); + + this._CSS = { + block: this.api.styles.block, + wrapper: 'ce-paragraph' + }; + + if (!this.readOnly) { + this.onKeyUp = this.onKeyUp.bind(this); + } + + this._placeholder = this.config.placeholder ? this.config.placeholder : Paragraph.DEFAULT_PLACEHOLDER; + this._data = {}; + this._element = this.drawView(); + this._preserveBlank = this.config.preserveBlank !== undefined ? this.config.preserveBlank : false; + + this.data = data; + this.col = this.data.col ? this.data.col : "12"; + } + + onKeyUp(e) { + if (e.code !== 'Backspace' && e.code !== 'Delete') { + return; + } + + const {textContent} = this._element; + + if (textContent === '') { + this._element.innerHTML = ''; + } + } + + drawView() { + let div = document.createElement('DIV'); + + div.classList.add(this._CSS.wrapper, this._CSS.block, 'widget'); + div.contentEditable = false; + div.dataset.placeholder = this.api.i18n.t(this._placeholder); + + if (!this.readOnly) { + div.contentEditable = true; + div.addEventListener('keyup', this.onKeyUp); + } + return div; + } + + render() { + this.wrapper = document.createElement('div'); + this.wrapper.contentEditable = this.readOnly ? 'false' : 'true'; + if (!this.readOnly) { + let $para_control = $(`
`); + + this.wrapper.appendChild(this._element); + this._element.classList.remove('widget'); + $para_control.appendTo(this.wrapper); + + this.wrapper.classList.add('widget'); + + frappe.utils.add_custom_button( + frappe.utils.icon('dot-horizontal', 'xs'), + (event) => { + let evn = event; + !$('.ce-settings.ce-settings--opened').length && + setTimeout(() => { + this.api.toolbar.toggleBlockSettings(); + var position = $(evn.target).offset(); + $('.ce-settings.ce-settings--opened').offset({ + top: position.top + 25, + left: position.left - 77 + }); + }, 50); + }, + "tune-btn", + `${__('Tune')}`, + null, + $para_control + ); + + frappe.utils.add_custom_button( + frappe.utils.icon('drag', 'xs'), + null, + "drag-handle", + `${__('Drag')}`, + null, + $para_control + ); + + frappe.utils.add_custom_button( + frappe.utils.icon('delete', 'xs'), + () => this.api.blocks.delete(), + "delete-paragraph", + `${__('Delete')}`, + null, + $para_control + ); + + return this.wrapper; + } + return this._element; + } + + merge(data) { + let newData = { + text: this.data.text + data.text + }; + + this.data = newData; + } + + validate(savedData) { + if (savedData.text.trim() === '' && !this._preserveBlank) { + return false; + } + + return true; + } + + save() { + this.wrapper = this._element; + return { + text: this.wrapper.innerHTML, + col: this.get_col(), + }; + } + + rendered() { + var e = this._element.closest('.ce-block'); + e.classList.add("col-" + this.get_col()); + } + + onPaste(event) { + const data = { + text: event.detail.data.innerHTML + }; + + this.data = data; + } + + static get conversionConfig() { + return { + export: 'text', // to convert Paragraph to other block, use 'text' property of saved data + import: 'text' // to covert other block's exported string to Paragraph, fill 'text' property of tool data + }; + } + + static get sanitize() { + return { + text: { + br: true, + b: true, + i: true, + a: true + } + }; + } + + static get isReadOnlySupported() { + return true; + } + + get data() { + let text = this._element.innerHTML; + + this._data.text = text; + + return this._data; + } + + set data(data) { + this._data = data || {}; + + this._element.innerHTML = this._data.text || ''; + } + + static get pasteConfig() { + return { + tags: [ 'P' ] + }; + } + + static get toolbox() { + return { + icon: '', + title: 'Text' + }; + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/shortcut.js b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js new file mode 100644 index 0000000000..f7482a06f3 --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js @@ -0,0 +1,57 @@ +import Block from "./block.js"; +export default class Shortcut extends Block { + static get toolbox() { + return { + title: 'Shortcut', + icon: '' + }; + } + + 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 + }; + } + + render() { + this.wrapper = document.createElement('div'); + this.new('shortcut'); + + if (this.data && this.data.shortcut_name) { + let has_data = this.make('shortcut', this.data.shortcut_name); + if (!has_data) return; + } + + if (!this.readOnly) { + this.add_tune_button(); + } + return this.wrapper; + } + + validate(savedData) { + if (!savedData.shortcut_name) { + return false; + } + + return true; + } + + save(blockContent) { + return { + shortcut_name: blockContent.getAttribute('shortcut_name'), + col: this.get_col(), + new: this.new_block_widget + }; + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/spacer.js b/frappe/public/js/frappe/views/workspace/blocks/spacer.js new file mode 100644 index 0000000000..3309cad4a4 --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/spacer.js @@ -0,0 +1,82 @@ +import Block from './block.js'; +export default class Spacer extends Block { + static get toolbox() { + return { + title: 'Spacer', + icon: '' + }; + } + + static get isReadOnlySupported() { + return true; + } + + constructor({ data, api, config, readOnly }) { + super({ data, api, config, readOnly }); + this.col = this.data.col ? this.data.col : "12"; + } + + render() { + this.wrapper = document.createElement('div'); + if (!this.readOnly) { + let $spacer = $(` +
+
+
Spacer
+
+
+ `); + $spacer.appendTo(this.wrapper); + + this.wrapper.classList.add('widget', 'new-widget'); + this.wrapper.style.minHeight = 50 + 'px'; + + let $widget_control = $spacer.find('.widget-control'); + + frappe.utils.add_custom_button( + frappe.utils.icon('dot-horizontal', 'xs'), + (event) => { + let evn = event; + !$('.ce-settings.ce-settings--opened').length && + setTimeout(() => { + this.api.toolbar.toggleBlockSettings(); + var position = $(evn.target).offset(); + $('.ce-settings.ce-settings--opened').offset({ + top: position.top + 25, + left: position.left - 77 + }); + }, 50); + }, + "tune-btn", + `${__('Tune')}`, + null, + $widget_control + ); + + frappe.utils.add_custom_button( + frappe.utils.icon('drag', 'xs'), + null, + "drag-handle", + `${__('Drag')}`, + null, + $widget_control + ); + + frappe.utils.add_custom_button( + frappe.utils.icon('delete', 'xs'), + () => this.api.blocks.delete(), + "delete-spacer", + `${__('Delete')}`, + null, + $widget_control + ); + } + return this.wrapper; + } + + save() { + return { + col: this.get_col() + }; + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/blocks/spacing_tune.js b/frappe/public/js/frappe/views/workspace/blocks/spacing_tune.js new file mode 100644 index 0000000000..365f7f590e --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/spacing_tune.js @@ -0,0 +1,123 @@ +export default class SpacingTune { + static get isTune() { + return true; + } + + constructor({api, settings}) { + this.api = api; + this.settings = settings; + this.CSS = { + button: 'ce-settings__button', + wrapper: 'ce-tune-layout', + sidebar: 'cdx-settings-sidebar', + animation: 'wobble', + }; + this.data = { colWidth: 12 }; + this.wrapper = undefined; + this.sidebar = undefined; + } + + render() { + let me = this; + let layoutWrapper = document.createElement('div'); + layoutWrapper.classList.add(this.CSS.wrapper); + let decreaseWidthButton = document.createElement('div'); + decreaseWidthButton.classList.add(this.CSS.button, 'ce-shrink-button'); + let increaseWidthButton = document.createElement('div'); + increaseWidthButton.classList.add(this.CSS.button, 'ce-expand-button'); + + layoutWrapper.appendChild(decreaseWidthButton); + layoutWrapper.appendChild(increaseWidthButton); + + decreaseWidthButton.innerHTML = ``; + this.api.tooltip.onHover(decreaseWidthButton, 'Shrink', { + placement: 'top', + hidingDelay: 500, + }); + this.api.listeners.on( + decreaseWidthButton, + 'click', + () => me.decreaseWidth(), + false + ); + + increaseWidthButton.innerHTML = ``; + this.api.tooltip.onHover(increaseWidthButton, 'Expand', { + placement: 'top', + hidingDelay: 500, + }); + this.api.listeners.on( + increaseWidthButton, + 'click', + () => me.increaseWidth(), + false + ); + + this.wrapper = layoutWrapper; + return layoutWrapper; + } + + decreaseWidth() { + const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); + + if (currentBlockIndex < 0) { + return; + } + + let currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex); + if (!currentBlock) { + return; + } + + let currentBlockElement = currentBlock.holder; + + let className = 'col-12'; + let colClass = new RegExp(/\bcol-.+?\b/, 'g'); + if (currentBlockElement.className.match(colClass)) { + currentBlockElement.classList.forEach( cn => { + if (cn.match(colClass)) { + className = cn; + } + }); + let parts = className.split('-'); + let width = parseInt(parts[1]); + if (width >= 4) { + currentBlockElement.classList.remove('col-'+width); + width = width - 1; + currentBlockElement.classList.add('col-'+width); + } + } + } + + increaseWidth() { + const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); + + if (currentBlockIndex < 0) { + return; + } + + const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex); + if (!currentBlock) { + return; + } + + const currentBlockElement = currentBlock.holder; + + let className = 'col-12'; + const colClass = new RegExp(/\bcol-.+?\b/, 'g'); + if (currentBlockElement.className.match(colClass)) { + currentBlockElement.classList.forEach( cn => { + if (cn.match(colClass)) { + className = cn; + } + }); + let parts = className.split('-'); + let width = parseInt(parts[1]); + if (width <= 11) { + currentBlockElement.classList.remove('col-'+width); + width = width + 1; + currentBlockElement.classList.add('col-'+width); + } + } + } +} \ No newline at end of file diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index f16228dca0..719645feea 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -1,3 +1,6 @@ +import EditorJS from '@editorjs/editorjs'; +import Undo from 'editorjs-undo'; + frappe.standard_pages['Workspaces'] = function() { var wrapper = frappe.container.add_page('Workspaces'); @@ -17,37 +20,60 @@ frappe.views.Workspace = class Workspace { constructor(wrapper) { this.wrapper = $(wrapper); this.page = wrapper.page; - this.prepare_container(); - this.show_or_hide_sidebar(); - this.setup_dropdown(); - this.pages = {}; - this.sidebar_items = {}; + this.blocks = frappe.wspace_block.blocks; + this.is_read_only = true; + this.new_page = null; + this.sorted_public_items = []; + this.sorted_private_items = []; + this.deleted_sidebar_items = []; + this.current_page = {}; + this.sidebar_items = { + 'public': {}, + 'private': {} + }; this.sidebar_categories = [ - "Modules", - "Domains", - "Places", - "Administration" + 'My Workspaces', + 'Public' ]; + this.tools = { + header: { + class: this.blocks['header'], + inlineToolbar: true + }, + paragraph: { + class: this.blocks['paragraph'], + inlineToolbar: true + }, + chart: { + class: this.blocks['chart'], + config: { + page_data: this.page_data || [] + } + }, + card: { + class: this.blocks['card'], + config: { + page_data: this.page_data || [] + } + }, + shortcut: { + class: this.blocks['shortcut'], + config: { + page_data: this.page_data || [] + } + }, + onboarding: { + class: this.blocks['onboarding'], + config: { + page_data: this.page_data || [] + } + }, + spacer: this.blocks['spacer'], + spacingTune: frappe.wspace_block.tunes['spacing_tune'], + }; - this.setup_workspaces(); - this.make_sidebar(); - } - - setup_workspaces() { - // workspaces grouped by categories - this.workspaces = {}; - for (let page of frappe.boot.allowed_workspaces) { - if (!this.workspaces[page.category]) { - this.workspaces[page.category] = []; - } - this.workspaces[page.category].push(page); - } - } - - show() { - let page = this.get_page_to_show(); - this.page.set_title(`${__(page)}`); - this.show_page(page); + this.prepare_container(); + this.setup_pages(); } prepare_container() { @@ -57,229 +83,183 @@ frappe.views.Workspace = class Workspace { `).appendTo(this.wrapper.find(".layout-side-section")); this.sidebar = list_sidebar.find(".desk-sidebar"); - this.body = this.wrapper.find(".layout-main-section"); } - get_page_to_show() { - let default_page; + setup_pages() { + this.get_pages().then(pages => { + this.all_pages = pages.pages; + this.has_access = pages.has_access; - if (localStorage.current_workspace) { - default_page = localStorage.current_workspace; - } else if (this.workspaces) { - default_page = this.workspaces["Modules"][0].name; - } else if (frappe.boot.allowed_workspaces) { - default_page = frappe.boot.allowed_workspaces[0].name; - } else { - default_page = "Build"; - } + this.all_pages.forEach(page => { + page.is_editable = !page.public || pages.has_access; + }); - let page = frappe.get_route()[1] || default_page; - return page; + this.public_pages = this.all_pages.filter(page => page.public); + this.private_pages = this.all_pages.filter(page => !page.public); + + if (this.all_pages) { + frappe.workspaces = {}; + for (let page of this.all_pages) { + frappe.workspaces[frappe.router.slug(page.title)] = {title: page.title}; + } + if (this.new_page && this.new_page.name) { + if (!frappe.workspaces[frappe.router.slug(this.new_page.name)]) { + this.new_page = { name: this.all_pages[0].title, public: this.all_pages[0].public }; + } + if (this.new_page.public) { + frappe.set_route(`${frappe.router.slug(this.new_page.name)}`); + } else { + frappe.set_route(`private/${frappe.router.slug(this.new_page.name)}`); + } + this.new_page = null; + } + this.make_sidebar(); + frappe.router.route(); + } + }); + } + + get_pages() { + return frappe.xcall("frappe.desk.desktop.get_wspace_sidebar_items"); + } + + sidebar_item_container(item) { + return $(` + + `); } make_sidebar() { + if (this.sidebar.find('.standard-sidebar-section')[0]) { + this.sidebar.find('.standard-sidebar-section').remove(); + } + this.sidebar_categories.forEach(category => { - if (this.workspaces[category]) { - this.build_sidebar_section(category, this.workspaces[category]); + let root_pages = this.public_pages.filter(page => page.parent_page == '' || page.parent_page == null); + if (category != 'Public') { + root_pages = this.private_pages.filter(page => page.parent_page == '' || page.parent_page == null); } - }); - } - - build_sidebar_section(title, items) { - let sidebar_section = $(`
`); - - // DO NOT REMOVE: Comment to load translation - // __("Modules") __("Domains") __("Places") __("Administration") - $(`
${__(title)}
`) - .appendTo(sidebar_section); - - const get_sidebar_item = function (item) { - return $(` - - ${frappe.utils.icon(item.icon || "folder-normal", "md")} - ${item.label || item.name} - - `); - }; - - const make_sidebar_category_item = item => { - if (item.name == this.get_page_to_show()) { - item.selected = true; - this.current_page_name = item.name; - } - - let $item = get_sidebar_item(item); - - $item.appendTo(sidebar_section); - this.sidebar_items[item.name] = $item; - }; - - items.forEach(item => make_sidebar_category_item(item)); - - sidebar_section.appendTo(this.sidebar); - } - - show_page(page) { - if (this.current_page_name && this.pages[this.current_page_name]) { - this.pages[this.current_page_name].hide(); - } - - if (this.sidebar_items && this.sidebar_items[this.current_page_name]) { - this.sidebar_items[this.current_page_name].removeClass("selected"); - this.sidebar_items[page].addClass("selected"); - } - this.current_page_name = page; - localStorage.current_workspace = page; - - this.pages[page] ? this.pages[page].show() : this.make_page(page); - this.current_page = this.pages[page]; - this.setup_dropdown(); - } - - make_page(page) { - const $page = new DesktopPage({ - container: this.body, - page_name: page + this.build_sidebar_section(category, root_pages); }); - this.pages[page] = $page; - return $page; + // Scroll sidebar to selected page if it is not in viewport. + !frappe.dom.is_element_in_viewport(this.sidebar.find('.selected')) + && this.sidebar.find('.selected')[0].scrollIntoView(); } - customize() { - if (this.current_page && this.current_page.allow_customization) { - this.page.clear_menu(); - this.current_page.customize(); + build_sidebar_section(title, root_pages) { + let sidebar_section = $(`
`); - this.page.set_primary_action( - __("Save Customizations"), - () => { - this.current_page.save_customization(); - this.page.clear_primary_action(); - this.page.clear_secondary_action(); - this.setup_dropdown(); - }, - null, - __("Saving") - ); + let $title = $(`
+ ${frappe.utils.icon("small-down", "xs")} + ${__(title)} +
`).appendTo(sidebar_section); + this.prepare_sidebar(root_pages, sidebar_section, this.sidebar); - this.page.set_secondary_action( - __("Discard"), - () => { - this.current_page.reload(); - frappe.show_alert({ message: __("Customizations Discarded"), indicator: "info" }); - this.page.clear_primary_action(); - this.page.clear_secondary_action(); - this.setup_dropdown(); - } - ); + $title.on('click', (e) => { + let icon = $(e.target).find("span use").attr("href")==="#icon-small-down" ? "#icon-right" : "#icon-small-down"; + $(e.target).find("span use").attr("href", icon); + $(e.target).parent().find('.sidebar-item-container').toggleClass('hidden'); + }); + + if (!this.current_page.name) { + $title.trigger("click"); + } + + if (Object.keys(root_pages).length === 0) { + sidebar_section.addClass('hidden'); } } - setup_dropdown() { - this.page.clear_menu(); - - this.page.set_secondary_action(__('Customize'), () => { - this.customize(); - }); - - this.page.add_menu_item(__('Reset Customizations'), () => { - this.current_page.reset_customization(); - }, 1); - - this.page.add_menu_item(__('Toggle Sidebar'), () => { - this.toggle_side_bar(); - }, 1); + prepare_sidebar(items, child_container, item_container) { + items.forEach(item => this.append_item(item, child_container)); + child_container.appendTo(item_container); } - toggle_side_bar() { - let show_workspace_sidebar = JSON.parse(localStorage.show_workspace_sidebar || "true"); - show_workspace_sidebar = !show_workspace_sidebar; - localStorage.show_workspace_sidebar = show_workspace_sidebar; - this.show_or_hide_sidebar(); - $(document.body).trigger("toggleDeskSidebar"); + append_item(item, container) { + let is_current_page = frappe.router.slug(item.title) == frappe.router.slug(this.get_page_to_show().name) + && item.public == this.get_page_to_show().public; + if (is_current_page) { + item.selected = true; + this.current_page = { name: item.title, public: item.public }; + } + + let $item_container = this.sidebar_item_container(item); + let sidebar_control = $item_container.find('.sidebar-item-control'); + + this.add_sidebar_actions(item, sidebar_control); + let pages = item.public ? this.public_pages : this.private_pages; + + let child_items = pages.filter(page => page.parent_page == item.title); + if (child_items.length > 0) { + let child_container = $(``); + this.prepare_sidebar(child_items, child_container, $item_container); + } + + $item_container.appendTo(container); + this.sidebar_items[item.public ? 'public' : 'private'][item.title] = $item_container; + + if ($item_container.parent().hasClass('hidden') && is_current_page) { + $item_container.parent().toggleClass('hidden'); + } + + this.add_drop_icon(item, sidebar_control, $item_container); } - show_or_hide_sidebar() { - let show_workspace_sidebar = JSON.parse(localStorage.show_workspace_sidebar || "true"); - $('#page-workspace .layout-side-section').toggleClass('hidden', !show_workspace_sidebar); - } -}; - -class DesktopPage { - constructor({ container, page_name }) { - frappe.desk_page = this; - this.container = container; - this.page_name = page_name; - this.sections = {}; - this.allow_customization = false; - this.reload(); - } - - show() { - frappe.desk_page = this; - this.page.show(); - if (this.sections.shortcuts) { - this.sections.shortcuts.widgets_list.forEach(wid => { - wid.set_actions(); + add_drop_icon(item, sidebar_control, item_container) { + let $child_item_section = item_container.find('.sidebar-child-item'); + let $drop_icon = $(``) + .appendTo(sidebar_control); + let pages = item.public ? this.public_pages : this.private_pages; + if (pages.some(e => e.parent_page == item.title)) { + $drop_icon.removeClass('hidden'); + $drop_icon.on('click', () => { + let icon = $drop_icon.find("use").attr("href")==="#icon-small-down" ? "#icon-small-up" : "#icon-small-down"; + $drop_icon.find("use").attr("href", icon); + $child_item_section.toggleClass("hidden"); }); } } - hide() { - this.page.hide(); - } - - reload() { - this.in_customize_mode = false; - this.page && this.page.remove(); - this.make(); - } - - make() { - this.page = $(`
`); - this.page.append(frappe.render_template('workspace_loading_skeleton')); - this.page.appendTo(this.container); - - this.get_data().then(() => { - if (Object.keys(this.data).length == 0) { - delete localStorage.current_workspace; - frappe.set_route("workspace"); - return; - } - this.refresh(); - }).finally(() => this.page.find('.workspace_loading_skeleton').remove()); - } - - refresh() { - this.page.empty(); - this.allow_customization = this.data.allow_customization || false; - - if (frappe.is_mobile()) { - this.allow_customization = false; + show() { + if (!this.all_pages) { + // pages not yet loaded, call again after a bit + setTimeout(() => this.show(), 100); + return; } - this.data.onboarding && this.data.onboarding.items.length && this.make_onboarding(); - this.make_charts(); - this.make_shortcuts(); - this.make_cards(); + let page = { + name: this.get_page_to_show().name, + public: this.get_page_to_show().public + }; + this.page.set_title(`${__(page.name)}`); + + this.show_page(page); } - get_data() { + get_data(page) { return frappe.xcall("frappe.desk.desktop.get_desktop_page", { - page: this.page_name + page: page }).then(data => { - this.data = data; - if (Object.keys(this.data).length == 0) return; + this.page_data = data; + if (!this.page_data || Object.keys(this.page_data).length === 0) return; return frappe.dashboard_utils.get_dashboard_settings().then(settings => { let chart_config = settings.chart_config ? JSON.parse(settings.chart_config) : {}; - if (this.data.charts.items) { - this.data.charts.items.map(chart => { + if (this.page_data.charts && this.page_data.charts.items) { + this.page_data.charts.items.map(chart => { chart.chart_settings = chart_config[chart.chart_name] || {}; }); } @@ -287,128 +267,493 @@ class DesktopPage { }); } - customize() { - if (this.in_customize_mode) { + get_page_to_show() { + let default_page; + + if (localStorage.current_page && this.all_pages.filter(page => page.title == localStorage.current_page).length != 0) { + default_page = { name: localStorage.current_page, public: localStorage.is_current_page_public == 'true' }; + } else if (Object.keys(this.all_pages).length !== 0) { + default_page = { name: this.all_pages[0].title, public: true }; + } else { + default_page = { name: "Build", public: true }; + } + + let page = (frappe.get_route()[1] == 'private' ? frappe.get_route()[2] : frappe.get_route()[1]) || default_page.name; + let is_public = frappe.get_route()[1] ? frappe.get_route()[1] != 'private' : default_page.public; + return { name: page, public: is_public }; + } + + show_page(page) { + let section = this.current_page.public ? 'public' : 'private'; + if (this.sidebar_items && this.sidebar_items[section] && this.sidebar_items[section][this.current_page.name]) { + this.sidebar_items[section][this.current_page.name][0].firstElementChild.classList.remove("selected"); + this.sidebar_items[page.public ? 'public':'private'][page.name][0].firstElementChild.classList.add("selected"); + + if (this.sidebar_items[page.public ? 'public':'private'][page.name].parents('.sidebar-item-container')[0]) { + this.sidebar_items[page.public ? 'public':'private'][page.name] + .parents('.sidebar-item-container') + .find('.drop-icon use') + .attr("href", "#icon-small-up"); + } + } + + this.current_page = { name: page.name, public: page.public }; + localStorage.current_page = page.name; + localStorage.is_current_page_public = page.public; + + if (!this.body.find('#editorjs')[0]) { + this.$page = $(` +
+ `).appendTo(this.body); + } + this.$page.prepend(frappe.render_template('workspace_loading_skeleton')); + this.$page.find('.codex-editor').addClass('hidden'); + + if (this.all_pages) { + let pages = page.public ? this.public_pages : this.private_pages; + let this_page = pages.filter(p => p.title == page.name)[0]; + this.setup_actions(page); + this.content = this_page && JSON.parse(this_page.content); + + this.add_custom_cards_in_content(); + + $('.item-anchor').addClass('disable-click'); + this.get_data(this_page).then(() => { + this.prepare_editorjs(); + $('.item-anchor').removeClass('disable-click'); + this.$page.find('.codex-editor').removeClass('hidden'); + this.$page.find('.workspace-skeleton').remove(); + }); + } + } + + add_custom_cards_in_content() { + let index = -1; + this.content.find((item, i) => { + if (item.type == 'card') index = i; + }); + if (index !== -1) { + this.content.splice(index+1, 0, {"type": "card", "data": {"card_name": "Custom Documents", "col": 4}}); + this.content.splice(index+2, 0, {"type": "card", "data": {"card_name": "Custom Reports", "col": 4}}); + } + } + + prepare_editorjs() { + if (this.editor) { + this.editor.isReady.then(() => { + this.editor.configuration.tools.chart.config.page_data = this.page_data; + 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.render({ blocks: this.content || [] }); + }); + } else { + this.initialize_editorjs(this.content); + } + } + + setup_actions(page) { + let pages = page.public ? this.public_pages : this.private_pages; + let current_page = pages.filter(p => p.title == page.name)[0]; + + if (!this.is_read_only) { + this.setup_customization_buttons(current_page); return; } - // We need to remove this as the chart group will be visible during customization - $('.widget.onboarding-widget-box').hide(); + this.page.clear_primary_action(); + this.page.clear_secondary_action(); + this.page.clear_inner_toolbar(); - Object.keys(this.sections).forEach(section => { - this.sections[section].customize(); + current_page.is_editable && this.page.set_secondary_action(__("Edit"), () => { + if (!this.editor || !this.editor.readOnly) return; + this.is_read_only = false; + this.editor.readOnly.toggle(); + this.editor.isReady.then(() => { + this.initialize_editorjs_undo(); + this.setup_customization_buttons(current_page); + this.show_sidebar_actions(); + this.make_sidebar_sortable(); + this.make_blocks_sortable(); + }); }); - this.in_customize_mode = true; + this.page.add_inner_button(__("Create Workspace"), () => { + this.initialize_new_page(); + }); } - save_customization() { + initialize_editorjs_undo() { + this.undo = new Undo({ editor: this.editor }); + this.undo.initialize({ blocks: this.content || [] }); + this.undo.readOnly = false; + } + + setup_customization_buttons(page) { + let me = this; + this.page.clear_primary_action(); + this.page.clear_secondary_action(); + this.page.clear_inner_toolbar(); + + page.is_editable && this.page.set_primary_action( + __("Save Customizations"), + () => { + this.page.clear_primary_action(); + this.page.clear_secondary_action(); + this.page.clear_inner_toolbar(); + this.undo.readOnly = true; + this.save_page(); + this.editor.readOnly.toggle(); + this.is_read_only = true; + }, + null, + __("Saving") + ); + + this.page.set_secondary_action( + __("Discard"), + () => { + this.page.clear_primary_action(); + this.page.clear_secondary_action(); + this.page.clear_inner_toolbar(); + this.editor.readOnly.toggle(); + this.is_read_only = true; + this.deleted_sidebar_items = []; + this.reload(); + frappe.show_alert({ message: __("Customizations Discarded"), indicator: "info" }); + } + ); + + page.name && this.page.add_inner_button(__("Settings"), () => { + frappe.set_route(`workspace/${page.name}`); + }); + + Object.keys(this.blocks).forEach(key => { + this.page.add_inner_button(` + ${this.blocks[key].toolbox.icon} + ${__(this.blocks[key].toolbox.title)} + `, function() { + const index = me.editor.blocks.getBlocksCount() + 1; + me.editor.blocks.insert(key, {}, {}, index, true); + me.editor.caret.setToLastBlock('start', 0); + $('.ce-block:last-child')[0].scrollIntoView(); + }, __('Add Block')); + }); + } + + show_sidebar_actions() { + this.sidebar.find('.standard-sidebar-section').addClass('show-control'); + } + + add_sidebar_actions(item, sidebar_control) { + if (!item.is_editable) { + $(`${frappe.utils.icon("lock", "sm")}`) + .appendTo(sidebar_control); + sidebar_control.parent().click(() => { + !this.is_read_only && frappe.show_alert({ + message: __("Only Workspace Manager can sort or edit this page"), + indicator: 'info' + }, 5); + }); + } else { + frappe.utils.add_custom_button( + frappe.utils.icon('drag', 'xs'), + null, + "drag-handle", + `${__('Drag')}`, + null, + sidebar_control + ); + frappe.utils.add_custom_button( + frappe.utils.icon('delete', 'xs'), + () => this.delete_page(item), + "delete-page", + `${__('Delete')}`, + null, + sidebar_control + ); + } + } + + delete_page(item) { + frappe.confirm(__("Are you sure you want to delete page {0}?", [item.title]), () => { + this.deleted_sidebar_items.push(item); + this.sidebar.find(`.standard-sidebar-section [item-name="${item.title}"][item-public="${item.public}"]`).addClass('hidden'); + }); + } + + make_sidebar_sortable() { + let me = this; + $('.nested-container').each( function() { + new Sortable(this, { + handle: ".drag-handle", + draggable: ".sidebar-item-container.is-draggable", + group: 'nested', + animation: 150, + fallbackOnBody: true, + swapThreshold: 0.65, + onEnd: function (evt) { + let is_public = $(evt.item).attr('item-public') == '1'; + me.prepare_sorted_sidebar(is_public); + } + }); + }); + } + + prepare_sorted_sidebar(is_public) { + if (is_public) { + this.sorted_public_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').last()); + } else { + this.sorted_private_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').first()); + } + } + + sort_sidebar($sidebar_section) { + let sorted_items = []; + for (let page of $sidebar_section.find('.sidebar-item-container')) { + let parent_page = ""; + if (page.closest('.nested-container').classList.contains('sidebar-child-item')) { + parent_page = page.parentElement.parentElement.attributes["item-name"].value; + } + sorted_items.push({ + title: page.attributes['item-name'].value, + parent_page: parent_page, + public: page.attributes['item-public'].value + }); + } + return sorted_items; + } + + make_blocks_sortable() { + let me = this; + this.page_sortable = Sortable.create(this.page.main.find(".codex-editor__redactor").get(0), { + handle: ".drag-handle", + draggable: ".ce-block", + animation: 150, + onEnd: function (evt) { + me.editor.blocks.move(evt.newIndex, evt.oldIndex); + }, + setData: function () { + //Do Nothing + } + }); + } + + initialize_new_page() { + this.public_parent_pages = ['', ...this.public_pages.filter(page => !page.parent_page).map(page => page.title)]; + this.private_parent_pages = ['', ...this.private_pages.filter(page => !page.parent_page).map(page => page.title)]; + var me = this; + const d = new frappe.ui.Dialog({ + title: __('Set Title'), + fields: [ + { + label: __('Title'), + fieldtype: 'Data', + fieldname: 'title', + reqd: 1 + }, + { + label: __('Parent'), + fieldtype: 'Select', + fieldname: 'parent', + options: this.private_parent_pages + }, + { + label: __('Public'), + fieldtype: 'Check', + fieldname: 'is_public', + depends_on: `eval:${this.has_access}`, + onchange: function() { + d.set_df_property('parent', 'options', + this.get_value() ? me.public_parent_pages : me.private_parent_pages); + } + }, + { + fieldtype: 'Column Break' + }, + { + label: __('Icon'), + fieldtype: 'Icon', + fieldname: 'icon' + }, + ], + primary_action_label: __('Create'), + primary_action: (values) => { + if (!this.validate_page(values)) return; + d.hide(); + this.initialize_editorjs_undo(); + this.setup_customization_buttons({is_editable: true}); + this.title = values.title; + this.icon = values.icon; + this.parent = values.parent; + this.public = values.is_public; + this.editor.render({ + blocks: [ + { + type: "header", + data: { + text: this.title, + level: 4 + } + } + ] + }).then(() => { + if (this.editor.configuration.readOnly) { + this.is_read_only = false; + this.editor.readOnly.toggle(); + } + this.add_page_to_sidebar(values); + this.show_sidebar_actions(); + this.make_sidebar_sortable(); + this.make_blocks_sortable(); + this.prepare_sorted_sidebar(values.is_public); + }); + } + }); + d.show(); + } + + validate_page(values) { + let message = ""; + let pages = values.is_public ? this.public_pages : this.private_pages; + + if (pages && pages.filter(p => p.title == values.title)[0]) { + message = "Page with title '{0}' already exist."; + } else if (frappe.router.doctype_route_exist(frappe.router.slug(values.title))) { + message = "Doctype with same route already exist. Please choose different title."; + } + + if (message) { + frappe.throw(__(message, [__(values.title)])); + return false; + } + return true; + } + + add_page_to_sidebar({title, icon, parent, is_public}) { + let $sidebar = $('.standard-sidebar-section'); + let item = { + title: title, + icon: icon, + parent_page: parent, + public: is_public + }; + let $sidebar_item = this.sidebar_item_container(item); + $sidebar_item.addClass('is-draggable'); + + frappe.utils.add_custom_button( + frappe.utils.icon('drag', 'xs'), + null, + "drag-handle", + `${__('Drag')}`, + null, + $sidebar_item.find('.sidebar-item-control') + ); + $sidebar_item.find('.sidebar-item-control .drag-handle').css('margin-right', '8px'); + + let $sidebar_section = is_public ? $sidebar[1] : $sidebar[0]; + + if (!parent) { + !is_public && $sidebar.last().removeClass('hidden'); + $sidebar_item.appendTo($sidebar_section); + } else { + let $item_container = $($sidebar_section).find(`[item-name="${parent}"]`); + let $child_section = $item_container.find('.sidebar-child-item'); + let $drop_icon = $item_container.find('.drop-icon'); + if (!$child_section[0]) { + $child_section = $(``) + .appendTo($item_container); + $drop_icon.toggleClass('hidden'); + } + $sidebar_item.appendTo($child_section); + $child_section.removeClass('hidden'); + $item_container.find('.drop-icon use').attr("href", "#icon-small-up"); + } + } + + initialize_editorjs(blocks) { + this.editor = new EditorJS({ + data: { + blocks: blocks || [] + }, + tools: this.tools, + autofocus: false, + tunes: ['spacingTune'], + readOnly: true, + logLevel: 'ERROR' + }); + } + + save_page() { frappe.dom.freeze(); - const config = {}; + let save = true; + if (!this.title && this.current_page) { + let pages = this.current_page.public ? this.public_pages : this.private_pages; + this.title = this.current_page.name; + this.public = pages.filter(p => p.title == this.title)[0].public; + save = false; + } else { + this.current_page = { name: this.title, public: this.public }; + } + let me = this; + this.editor.save().then((outputData) => { + let new_widgets = {}; + outputData.blocks.forEach(item => { + if (item.data.new) { + if (!new_widgets[item.type]) { + new_widgets[item.type] = []; + } + new_widgets[item.type].push(item.data.new); + delete item.data['new']; + } + }); - if (this.sections.charts) config.charts = this.sections.charts.get_widget_config(); - if (this.sections.shortcuts) config.shortcuts = this.sections.shortcuts.get_widget_config(); - if (this.sections.cards) config.cards = this.sections.cards.get_widget_config(); + let blocks = outputData.blocks.filter( + item => item.type != 'card' || + (item.data.card_name !== 'Custom Documents' && + item.data.card_name !== 'Custom Reports') + ); - frappe.call('frappe.desk.desktop.save_customization', { - page: this.page_name, - config: config - }).then(res => { - frappe.dom.unfreeze(); - if (res.message) { - frappe.show_alert({ message: __("Customizations Saved Successfully"), indicator: "green" }); - this.reload(); - } else { - frappe.throw({ message: __("Something went wrong while saving customizations"), indicator: "red" }); - this.reload(); - } + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.save_page", + args: { + title: me.title, + icon: me.icon || '', + parent: me.parent || '', + public: me.public || 0, + sb_public_items: me.sorted_public_items, + sb_private_items: me.sorted_private_items, + deleted_pages: me.deleted_sidebar_items, + new_widgets: new_widgets, + blocks: JSON.stringify(blocks), + save: save + }, + callback: function(res) { + frappe.dom.unfreeze(); + if (res.message) { + me.new_page = res.message; + me.title = ''; + me.icon = ''; + me.parent = ''; + me.public = false; + me.sorted_public_items = []; + me.sorted_private_items = []; + me.deleted_sidebar_items = []; + me.reload(); + frappe.show_alert({ message: __("Page Saved Successfully"), indicator: "green" }); + } + } + }); + }).catch((error) => { + error; + // console.log('Saving failed: ', error); }); } - reset_customization() { - frappe.call('frappe.desk.desktop.reset_customization', { - page: this.page_name - }).then(() => { - frappe.show_alert({ message: __("Removed page customizations"), indicator: "green" }); - this.reload(); - }); + reload() { + this.$page.prepend(frappe.render_template('workspace_loading_skeleton')); + this.$page.find('.codex-editor').addClass('hidden'); + this.setup_pages(); + this.undo.readOnly = true; } - - make_onboarding() { - this.onboarding_widget = frappe.widget.make_widget({ - label: this.data.onboarding.label || __("Let's Get Started"), - subtitle: this.data.onboarding.subtitle, - steps: this.data.onboarding.items, - success: this.data.onboarding.success, - docs_url: this.data.onboarding.docs_url, - user_can_dismiss: this.data.onboarding.user_can_dismiss, - widget_type: 'onboarding', - container: this.page, - options: { - allow_sorting: false, - allow_create: false, - allow_delete: false, - allow_hiding: false, - allow_edit: false, - max_widget_count: 2, - } - }); - } - - make_charts() { - this.sections["charts"] = new frappe.widget.WidgetGroup({ - container: this.page, - type: "chart", - columns: 1, - class_name: "widget-charts", - hidden: Boolean(this.onboarding_widget), - options: { - allow_sorting: this.allow_customization, - allow_create: this.allow_customization, - allow_delete: this.allow_customization, - allow_hiding: false, - allow_edit: true, - max_widget_count: 2, - }, - widgets: this.data.charts.items - }); - } - - make_shortcuts() { - this.sections["shortcuts"] = new frappe.widget.WidgetGroup({ - title: this.data.shortcuts.label || __('Your Shortcuts'), - container: this.page, - type: "shortcut", - columns: 3, - options: { - allow_sorting: this.allow_customization, - allow_create: this.allow_customization, - allow_delete: this.allow_customization, - allow_hiding: false, - allow_edit: true, - }, - widgets: this.data.shortcuts.items - }); - } - - make_cards() { - let cards = new frappe.widget.WidgetGroup({ - title: this.data.cards.label || __("Reports & Masters"), - container: this.page, - type: "links", - columns: 3, - options: { - allow_sorting: this.allow_customization, - allow_create: false, - allow_delete: false, - allow_hiding: this.allow_customization, - allow_edit: false, - }, - widgets: this.data.cards.items - }); - - this.sections["cards"] = cards; - } -} - - +}; \ No newline at end of file diff --git a/frappe/public/js/frappe/widgets/base_widget.js b/frappe/public/js/frappe/widgets/base_widget.js index 9bbfb916e5..e6ae64d9dc 100644 --- a/frappe/public/js/frappe/widgets/base_widget.js +++ b/frappe/public/js/frappe/widgets/base_widget.js @@ -25,18 +25,23 @@ export default class Widget { this.action_area.empty(); options.allow_sorting && - this.add_custom_button( + frappe.utils.add_custom_button( frappe.utils.icon('drag', 'xs'), null, "drag-handle", + `${__('Drag')}`, + null, + this.action_area ); options.allow_delete && - this.add_custom_button( + frappe.utils.add_custom_button( frappe.utils.icon('delete', 'xs'), () => this.delete(), "", - `${__('Delete')}` + `${__('Delete')}`, + null, + this.action_area ); if (options.allow_hiding) { @@ -48,11 +53,13 @@ export default class Widget { } const classname = this.hidden ? 'fa fa-eye' : 'fa fa-eye-slash'; const title = this.hidden ? `${__('Show')}` : `${__('Hide')}`; - this.add_custom_button( + frappe.utils.add_custom_button( ``, () => this.hide_or_show(), "show-or-hide-button", - title + title, + null, + this.action_area ); this.show_or_hide_button = this.action_area.find( @@ -61,18 +68,24 @@ export default class Widget { } options.allow_edit && - this.add_custom_button( + frappe.utils.add_custom_button( frappe.utils.icon("edit", "xs"), - () => this.edit() + () => this.edit(), + null, + `${__('Edit')}`, + null, + this.action_area ); if (options.allow_resize) { const title = this.width == 'Full'? `${__('Collapse')}` : `${__('Expand')}`; - this.add_custom_button( + frappe.utils.add_custom_button( '', () => this.toggle_width(), "resize-button", - title + title, + null, + this.action_area ); this.resize_button = this.action_area.find( @@ -88,12 +101,11 @@ export default class Widget { make_widget() { this.widget = $(`
-
-
+
+
@@ -114,37 +126,25 @@ export default class Widget { } set_title(max_chars) { - let base = this.label || this.name; + let base = this.title || this.label || this.name; let title = max_chars ? frappe.ellipsis(base, max_chars) : base; if (this.icon) { let icon = frappe.utils.icon(this.icon); - this.title_field[0].innerHTML = `${icon} ${title}`; + this.title_field[0].innerHTML = `${icon} ${title}`; } else { - this.title_field[0].innerHTML = title; + this.title_field[0].innerHTML = `${title}`; if (max_chars) { - this.title_field[0].setAttribute('title', this.label); + this.title_field[0].setAttribute('title', this.title || this.label); } } this.subtitle && this.subtitle_field.html(this.subtitle); } - add_custom_button(html, action, class_name = "", title="", btn_type) { - if (!btn_type) btn_type = 'btn-secondary'; - let button = $( - `` - ); - button.click(event => { - event.stopPropagation(); - action && action(); - }); - button.appendTo(this.action_area); - } - - delete(animate=true) { + delete(animate=true, dismissed=false) { let remove_widget = (setup_new) => { this.widget.remove(); - this.options.on_delete && this.options.on_delete(this.name, setup_new); + !dismissed && this.options.on_delete && this.options.on_delete(this.name, setup_new); }; if (animate) { @@ -168,8 +168,9 @@ export default class Widget { primary_action: (data) => { Object.assign(this, data); data.name = this.name; - + this.new = true; this.refresh(); + this.options.on_edit && this.options.on_edit(data); }, primary_action_label: __("Save") }); diff --git a/frappe/public/js/frappe/widgets/chart_widget.js b/frappe/public/js/frappe/widgets/chart_widget.js index 0c36f013ec..44f3edc038 100644 --- a/frappe/public/js/frappe/widgets/chart_widget.js +++ b/frappe/public/js/frappe/widgets/chart_widget.js @@ -349,6 +349,8 @@ export default class ChartWidget extends Widget { } setup_filter_button() { + if (this.in_customize_mode) return; + this.is_document_type = this.chart_doc.chart_type !== "Report" && this.chart_doc.chart_type !== "Custom"; diff --git a/frappe/public/js/frappe/widgets/links_widget.js b/frappe/public/js/frappe/widgets/links_widget.js index 9d0cadc1d0..cc771b96b5 100644 --- a/frappe/public/js/frappe/widgets/links_widget.js +++ b/frappe/public/js/frappe/widgets/links_widget.js @@ -12,14 +12,18 @@ export default class LinksWidget extends Widget { return { name: this.name, links: JSON.stringify(this.links), + link_count: this.links.length, label: this.label, hidden: this.hidden, }; } set_body() { - this.options = {}; - this.options.links = this.links; + + if (!this.options) { + this.options = {}; + this.options.links = this.links; + } this.widget.addClass("links-widget-box"); const is_link_disabled = item => { return item.dependencies && item.incomplete_dependencies; @@ -81,7 +85,9 @@ export default class LinksWidget extends Widget { ${get_link_for_item(item)} `); }); - + if (this.in_customize_mode) { + this.body.empty(); + } this.link_list.forEach(link => link.appendTo(this.body)); } diff --git a/frappe/public/js/frappe/widgets/onboarding_widget.js b/frappe/public/js/frappe/widgets/onboarding_widget.js index b487c0134f..7237de2fb6 100644 --- a/frappe/public/js/frappe/widgets/onboarding_widget.js +++ b/frappe/public/js/frappe/widgets/onboarding_widget.js @@ -3,7 +3,23 @@ import Widget from "./base_widget.js"; frappe.provide("frappe.utils"); export default class OnboardingWidget extends Widget { + + async refresh() { + this.new && await this.get_onboarding_data(); + this.set_title(); + this.set_actions(); + this.set_body(); + this.setup_events(); + } + + get_config() { + return { + label: this.onboarding_name + }; + } + make_body() { + this.body.empty(); this.steps_wrapper = $(`
`).appendTo(this.body); this.step_preview = $(`
@@ -477,11 +493,13 @@ export default class OnboardingWidget extends Widget { } is_dismissed() { + if (this.in_customize_mode) return false; + let dismissed = JSON.parse( localStorage.getItem("dismissed-onboarding") || "{}" ); - if (Object.keys(dismissed).includes(this.label)) { - let last_hidden = new Date(dismissed[this.label]); + if (Object.keys(dismissed).includes(this.title)) { + let last_hidden = new Date(dismissed[this.title]); let today = new Date(); let diff = frappe.datetime.get_hour_diff(today, last_hidden); return diff < 24; @@ -490,6 +508,8 @@ export default class OnboardingWidget extends Widget { } set_actions() { + if (this.in_customize_mode) return; + this.action_area.empty(); const dismiss = $( `
${__('Dismiss', null, 'Stop showing the onboarding widget.')}
` @@ -498,14 +518,38 @@ export default class OnboardingWidget extends Widget { let dismissed = JSON.parse( localStorage.getItem("dismissed-onboarding") || "{}" ); - dismissed[this.label] = frappe.datetime.now_datetime(); + dismissed[this.title] = frappe.datetime.now_datetime(); localStorage.setItem( "dismissed-onboarding", JSON.stringify(dismissed) ); - this.delete(); + this.delete(true, true); + this.widget.closest('.ce-block').hide(); }); dismiss.appendTo(this.action_area); } + + get_onboarding_data() { + return frappe.model + .with_doc("Module Onboarding", this.onboarding_name) + .then(onboarding_doc => { + if (onboarding_doc) { + this.onboarding_doc = onboarding_doc; + this.label = onboarding_doc.label; + this.title = onboarding_doc.title || __("Let's Get Started"); + this.subtitle = onboarding_doc.subtitle; + this.success = onboarding_doc.success; + this.docs_url = onboarding_doc.docs_url; + this.user_can_dismiss = onboarding_doc.user_can_dismiss; + const method = + "frappe.desk.doctype.onboarding_step.onboarding_step.get_onboarding_steps"; + return frappe + .xcall(method, { ob_steps: onboarding_doc.steps }) + .then(steps => { + this.steps = steps; + }); + } + }); + } } diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index 3f5a4acd73..9262627f02 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -124,6 +124,116 @@ class ChartDialog extends WidgetDialog { } } +class OnboardingDialog extends WidgetDialog { + constructor(opts) { + super(opts); + } + + get_fields() { + return [ + { + fieldtype: "Link", + fieldname: "onboarding_name", + label: "Onboarding Name", + options: "Module Onboarding", + reqd: 1, + } + ]; + } +} + +class CardDialog extends WidgetDialog { + constructor(opts) { + super(opts); + } + + get_fields() { + let me = this; + return [ + { + fieldtype: "Data", + fieldname: "label", + label: "Label", + }, + { + fieldname: 'links', + fieldtype: 'Table', + label: __('Card Links'), + editable_grid: 1, + data: me.values ? JSON.parse(me.values.links) : [], + get_data: () => { + return me.values ? JSON.parse(me.values.links) : []; + }, + fields: [ + { + fieldname: "label", + fieldtype: "Data", + in_list_view: 1, + label: "Label" + }, + { + fieldname: "icon", + fieldtype: "Data", + label: "Icon" + }, + { + fieldname: "link_type", + fieldtype: "Select", + in_list_view: 1, + label: "Link Type", + options: ["DocType", "Page", "Report"], + onchange: (e) => { + me.link_to = e.currentTarget.value; + } + }, + { + fieldname: "link_to", + fieldtype: "Dynamic Link", + in_list_view: 1, + label: "Link To", + options: "link_type", + get_options: () => { + return me.link_to; + } + }, + { + fieldname: "column_break_7", + fieldtype: "Column Break" + }, + { + fieldname: "dependencies", + fieldtype: "Data", + label: "Dependencies" + }, + { + fieldname: "only_for", + fieldtype: "Link", + label: "Only for ", + options: "Country" + }, + { + default: "0", + fieldname: "onboard", + fieldtype: "Check", + label: "Onboard" + }, + { + default: "0", + fieldname: "is_query_report", + fieldtype: "Check", + label: "Is Query Report" + } + ], + }, + ]; + } + + process_data(data) { + data.label = data.label ? data.label : data.chart_name; + return data; + } +} + class ShortcutDialog extends WidgetDialog { constructor(opts) { super(opts); @@ -438,6 +548,8 @@ export default function get_dialog_constructor(type) { chart: ChartDialog, shortcut: ShortcutDialog, number_card: NumberCardDialog, + links: CardDialog, + onboarding: OnboardingDialog }; return widget_map[type] || WidgetDialog; diff --git a/frappe/public/js/frappe/widgets/widget_group.js b/frappe/public/js/frappe/widgets/widget_group.js index 20a8e14540..d8f92edc5d 100644 --- a/frappe/public/js/frappe/widgets/widget_group.js +++ b/frappe/public/js/frappe/widgets/widget_group.js @@ -186,4 +186,51 @@ export default class WidgetGroup { } } +export class SingleWidgetGroup { + constructor(opts) { + Object.assign(this, opts); + this.widgets_list = []; + this.widgets_dict = {}; + this.widget_order = []; + this.make(); + } + + make() { + this.add_widget(this.widgets); + } + + add_widget(widget) { + let widget_object = frappe.widget.make_widget({ + ...widget, + widget_type: this.type, + container: this.container, + height: this.height || null, + options: { + ...this.options, + on_delete: () => this.on_delete(), + on_edit: () => this.on_edit(widget_object) + } + }); + this.widgets_list.push(widget_object); + this.widgets_dict[widget.name] = widget_object; + + return widget_object; + } + + on_delete() { + this.api.blocks.delete(); + } + + on_edit(widget_object) { + this.block.call("on_edit", widget_object); + } + + customize() { + this.widgets_list.forEach((wid) => { + wid.customize(this.options); + }); + } +} + frappe.widget.WidgetGroup = WidgetGroup; +frappe.widget.SingleWidgetGroup = SingleWidgetGroup; diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss index ac3b1a4f7c..49ed07bbce 100644 --- a/frappe/public/scss/desk/desktop.scss +++ b/frappe/public/scss/desk/desktop.scss @@ -9,6 +9,11 @@ body { .standard-sidebar-label { font-size: var(--text-xs); text-transform: uppercase; + cursor: pointer; + + span { + pointer-events: none; + } } .standard-sidebar-section { @@ -128,19 +133,25 @@ body { .widget-head { @include flex(flex, space-between, center, null); - .widget-title { - @include flex(flex, null, center, null); - font-size: var(--text-lg); - font-family: inherit; - font-weight: 500; - line-height: 1.3em; - color: var(--heading-color); + .widget-label { + min-width: 0px; - svg { - margin-right: 6px; - box-shadow: none; + .widget-title { + @include flex(flex, null, center, null); + font-size: var(--text-lg); + font-family: inherit; + font-weight: 500; + line-height: 1.3em; + color: var(--heading-color); + + svg { + flex: none; + margin-right: 6px; + box-shadow: none; + } } } + .widget-control { @include flex(flex, null, center, row-reverse); @@ -781,4 +792,267 @@ body { } } } + + .block-menu-item-icon svg{ + width: 12px; + height: 12px; + margin-right: 5px; + } + + .standard-sidebar-item { + justify-content: space-between; + padding: 0px; + + .sidebar-item-control { + + > * { + align-self: center; + margin-left: 3px; + box-shadow: none; + } + + .drag-handle { + cursor: all-scroll; + cursor: -webkit-grabbing; + display: none; + } + + .delete-page { + display: none; + } + + .drop-icon { + padding: 10px 12px 10px 2px; + } + + .sidebar-info { + display: none; + } + + svg { + margin-right: 0; + } + } + + .sidebar-item-label { + flex: 1; + } + + .item-anchor { + display: flex; + overflow: hidden; + padding: 8px 0px 8px 12px; + flex: 1; + } + } + + .sidebar-item-container { + .sidebar-item-container{ + margin-left: 10px; + + .standard-sidebar-item { + justify-content: start; + } + } + } + + .standard-sidebar-section.show-control { + .desk-sidebar-item.standard-sidebar-item { + + &:hover, &.selected { + .drag-handle { + display: inline-block; + } + + .delete-page { + display: inline-block; + margin-right: 8px; + } + + .sidebar-info { + display: inline-block; + margin-right: 8px; + } + + .drop-icon { + padding: 10px 8px 10px 2px; + margin-left: -4px; + } + } + + .block-click { + pointer-events:none; + } + } + } + + .codex-editor { + min-height: 630px; + + .codex-editor__redactor{ + display: flex; + flex-wrap: wrap; + flex-direction: row; + margin: 0px -7px; + padding-bottom: 20px !important; + + .ce-block{ + width: 100%; + padding-left: 0; + padding-right: 0; + + &.ce-block--selected { + .ce-block__content { + background-color: inherit; + } + } + + .ce-block__content { + max-width: 100%; + height: 100%; + padding: 7px; + + &> div { + height: 100%; + } + + .tune-btn > * { + pointer-events: none; + } + + .ce-header { + padding: 0 !important; + margin-bottom: 0 !important; + flex: 1; + } + + .widget{ + &.header { + display: flex; + justify-content: center; + flex: 1; + padding-left: 15px !important; + padding-right: 15px !important; + min-height: 50px; + box-shadow: none; + background-color: var(--control-bg); + color: var(--text-muted); + } + + &:focus { + outline: none; + } + + &.new-widget { + align-items: inherit; + } + + &.ce-paragraph { + display: block; + } + + .paragraph-control { + display: flex; + flex-direction: row-reverse; + position: absolute; + right: 20px; + gap: 5px; + background-color: var(--card-bg); + padding-left: 5px; + + .drag-handle { + cursor: all-scroll; + cursor: -webkit-grabbing; + } + } + } + } + } + } + + svg { + fill: none; + } + + .ce-toolbar { + svg { + fill: currentColor; + } + + .icon { + stroke: none; + width: fit-content; + height: fit-content; + } + + .ce-settings { + width: fit-content; + + .ce-settings__button, .cdx-settings-button { + color: #707684; + } + + .cdx-settings-button--active { + color: #388ae5; + } + + .cdx-settings-button.disabled{ + pointer-events: none; + opacity: .5 + } + .cdx-settings-sidebar{ + position: absolute; + right: 100%; + top:0; + background: #fff; + width: 108px; + height: 145px; + box-shadow: 0 3px 15px -3px rgba(13,20,33,.13); + border-radius: 0 4px 4px 0;z-index: 0; + } + } + + .ce-toolbar__settings-btn { + display: none; + } + } + + .ce-inline-tool, .ce-inline-toolbar__dropdown { + .icon { + fill: currentColor; + } + } + + @media (min-width: 1199px) { + .ce-toolbar__content { + max-width: 930px; + } + } + @media (max-width: 995px) { + .ce-toolbar__content { + max-width: 760px; + } + } + + @media (max-width: 1199px) { + .ce-block.col-4 { + flex: 0 0 50%; + max-width: 50%; + } + } + + @media (max-width: 750px) { + .ce-block.col-4 { + flex: 0 0 100%; + max-width: 100%; + } + } + @media (max-width: 750px) { + .ce-block.col-6 { + flex: 0 0 100%; + max-width: 100%; + } + } + + } } diff --git a/frappe/public/scss/desk/global.scss b/frappe/public/scss/desk/global.scss index d1205e0e38..333ee30e4d 100644 --- a/frappe/public/scss/desk/global.scss +++ b/frappe/public/scss/desk/global.scss @@ -126,6 +126,12 @@ pre { } } +.hidden-xl { + @include media-breakpoint-up(lg) { + display: none !important; + } +} + .visible-xs { @include media-breakpoint-up(sm) { display: none !important; diff --git a/frappe/query_builder/__init__.py b/frappe/query_builder/__init__.py index 798c34b6cc..f1503c88b8 100644 --- a/frappe/query_builder/__init__.py +++ b/frappe/query_builder/__init__.py @@ -1 +1 @@ -from frappe.query_builder.utils import get_query_builder +from frappe.query_builder.utils import get_query_builder, patch_query_execute diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index b52a3606e8..67e2c392f3 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -1,5 +1,6 @@ from enum import Enum -from typing import Any, Callable, Dict +from typing import Any, Callable, Dict, get_type_hints +from importlib import import_module from pypika import Query @@ -19,6 +20,9 @@ class ImportMapper: db = db_type_is(frappe.conf.db_type or "mariadb") return self.func_map[db](*args, **kwds) +class BuilderIdentificationFailed(Exception): + def __init__(self): + super().__init__("Couldn't guess builder") def get_query_builder(type_of_db: str) -> Query: """[return the query builder object] @@ -32,3 +36,25 @@ def get_query_builder(type_of_db: str) -> Query: db = db_type_is(type_of_db) picks = {db_type_is.MARIADB: MariaDB, db_type_is.POSTGRES: Postgres} return picks[db] + +def get_attr(method_string): + modulename = '.'.join(method_string.split('.')[:-1]) + methodname = method_string.split('.')[-1] + return getattr(import_module(modulename), methodname) + +def patch_query_execute(): + """Patch the Query Builder with helper execute method + This excludes the use of `frappe.db.sql` method while + executing the query object + """ + + def execute_query(query, **kwargs): + return frappe.db.sql(query, **kwargs) + + query_class = get_attr(str(frappe.qb).split("'")[1]) + builder_class = get_type_hints(query_class._builder).get('return') + + if not builder_class: + raise BuilderIdentificationFailed + + builder_class.run = execute_query diff --git a/frappe/templates/emails/password_reset.html b/frappe/templates/emails/password_reset.html index d9e38e38f2..cd0d820615 100644 --- a/frappe/templates/emails/password_reset.html +++ b/frappe/templates/emails/password_reset.html @@ -3,5 +3,5 @@

{{_("Reset your password")}}

{{_("Thank you")}},
- {{ user_fullname }} + {{ created_by }}

diff --git a/frappe/templates/includes/feedback/feedback.html b/frappe/templates/includes/feedback/feedback.html index 55d44e95fb..f180fa5e42 100644 --- a/frappe/templates/includes/feedback/feedback.html +++ b/frappe/templates/includes/feedback/feedback.html @@ -7,9 +7,6 @@
-
- -
{% for rating in [1, 2, 3, 4, 5 ,6, 7, 8, 9, 10] %} @@ -41,7 +38,6 @@ feedback && $("#submit-feedback").html(__("Update")); if (frappe.is_user_logged_in()) { - $(".feedback_email").parent().toggleClass("hidden"); if (feedback) { $("[name='feedback']").val(feedback); toggle_feedback(); @@ -83,12 +79,12 @@ $('#submit-feedback').click((ev) => { let update = ev.target.innerText !== __("Submit"); + let rating = $('.rating').find('.rating-click').length; let args = { reference_doctype: "{{ reference_doctype or doctype }}", reference_name: "{{ reference_name or name }}", rating: rating, - feedback: $("[name='feedback']").val(), - feedback_email: $("[name='feedback_email']").val() || frappe.user_id + feedback: $("[name='feedback']").val() } if (args.rating == 0) { @@ -101,16 +97,14 @@ return false; } - if (args.feedback_email!=='Administrator' && !validate_email(args.feedback_email)) { - frappe.msgprint("{{ _("Please enter a valid email address.") }}"); - return false; - } - if (!update) { frappe.call({ method: "frappe.templates.includes.feedback.feedback.add_feedback", args: args, callback: function(r) { + if (!r.message) { + return + } toggle_feedback(); if (!frappe.is_user_logged_in()) { $("[name='feedback']").val(''); diff --git a/frappe/templates/includes/feedback/feedback.py b/frappe/templates/includes/feedback/feedback.py index 1830a3e09e..b15d9567d7 100644 --- a/frappe/templates/includes/feedback/feedback.py +++ b/frappe/templates/includes/feedback/feedback.py @@ -3,21 +3,33 @@ from __future__ import unicode_literals import frappe +from frappe.utils import add_to_date, now from frappe import _ @frappe.whitelist(allow_guest=True) -def add_feedback(reference_doctype, reference_name, rating, feedback, feedback_email): +def add_feedback(reference_doctype, reference_name, rating, feedback): doc = frappe.get_doc(reference_doctype, reference_name) if doc.disable_feedback == 1: return + feedback_count = frappe.db.count("Feedback", { + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "ip_address": frappe.local.request_ip, + "creation": (">", add_to_date(now(), hours=-1)) + }) + + if feedback_count > 20: + frappe.msgprint(_('Hourly feedback limit reached')) + return + doc = frappe.new_doc('Feedback') doc.reference_doctype = reference_doctype doc.reference_name = reference_name doc.rating = rating doc.feedback = feedback - doc.email = feedback_email + doc.ip_address = frappe.local.request_ip doc.save(ignore_permissions=True) subject = _('New Feedback on {0}: {1}').format(reference_doctype, reference_name) @@ -25,13 +37,13 @@ def add_feedback(reference_doctype, reference_name, rating, feedback, feedback_e return doc @frappe.whitelist() -def update_feedback(reference_doctype, reference_name, rating, feedback, feedback_email): +def update_feedback(reference_doctype, reference_name, rating, feedback): doc = frappe.get_doc(reference_doctype, reference_name) if doc.disable_feedback == 1: return filters = { - "email": feedback_email, + "owner": frappe.session.user, "reference_doctype": reference_doctype, "reference_name": reference_name } @@ -49,7 +61,7 @@ def send_mail(feedback, subject): doc = frappe.get_doc(feedback.reference_doctype, feedback.reference_name) message = ("

{0} ({1})

".format(feedback.feedback, feedback.rating) - + "

{2}

".format(frappe.utils.get_request_site_address(), + + "

{2}

".format(frappe.utils.get_request_site_address(), feedback.name, _("View Feedback"))) diff --git a/frappe/tests/test_query_builder.py b/frappe/tests/test_query_builder.py index c5ad457b2a..7a0935a63b 100644 --- a/frappe/tests/test_query_builder.py +++ b/frappe/tests/test_query_builder.py @@ -42,6 +42,12 @@ class TestBuilderBase(object): self.assertEqual("__Auth", frappe.qb.DocType("__Auth").get_sql()) self.assertEqual("Notes", frappe.qb.Table("Notes").get_sql()) + def test_run_patcher(self): + query = frappe.qb.from_("ToDo").select("*").limit(1) + data = query.run(as_dict=True) + self.assertTrue("run" in dir(query)) + self.assertIsInstance(query.run, Callable) + self.assertIsInstance(data, list) @run_only_if(db_type_is.MARIADB) class TestBuilderMaria(unittest.TestCase, TestBuilderBase): diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index f1c4f3b3f5..818dc8bce6 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -280,6 +280,16 @@ class TestWebsite(unittest.TestCase): frappe.flags.force_website_cache = False + def test_safe_render(self): + content = get_response_content('/_test/_test_safe_render_on') + self.assertNotIn("Safe Render On", content) + self.assertIn("frappe.exceptions.ValidationError: Illegal template", content) + + content = get_response_content('/_test/_test_safe_render_off') + self.assertIn("Safe Render Off", content) + self.assertIn("test.__test", content) + self.assertNotIn("frappe.exceptions.ValidationError: Illegal template", content) + def set_home_page_hook(key, value): from frappe import hooks diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 072e3a7c62..be941abd51 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -442,10 +442,10 @@ def search(text, start=0, limit=20, doctype=""): elif allowed_doctypes: query = query.where(global_search.doctype.isin(allowed_doctypes)) - if start > 0: + if cint(start) > 0: query = query.offset(start) - result = frappe.db.sql(query, as_dict=True) + result = query.run(as_dict=True) results.extend(result) diff --git a/frappe/website/doctype/blog_post/blog_post.py b/frappe/website/doctype/blog_post/blog_post.py index 965fc8e3e0..cb887a2ffc 100644 --- a/frappe/website/doctype/blog_post/blog_post.py +++ b/frappe/website/doctype/blog_post/blog_post.py @@ -147,12 +147,15 @@ class BlogPost(WebsiteGenerator): context.comment_text = _('{0} comments').format(len(context.comment_list)) def load_feedback(self, context): + user = frappe.session.user + if user == 'Guest': + user = '' feedback = frappe.get_all('Feedback', - fields=['email', 'feedback', 'rating'], + fields=['feedback', 'rating'], filters=dict( reference_doctype=self.doctype, reference_name=self.name, - email=frappe.session.user + owner=user ) ) context.user_feedback = feedback[0] if feedback else '' diff --git a/frappe/website/page_renderers/template_page.py b/frappe/website/page_renderers/template_page.py index 3ece8ff5d0..cf017be30b 100644 --- a/frappe/website/page_renderers/template_page.py +++ b/frappe/website/page_renderers/template_page.py @@ -204,7 +204,12 @@ class TemplatePage(BaseTemplatePage): if self.template_path.endswith('min.js'): html = self.source # static else: - html = frappe.render_template(self.source, self.context) + if self.context.safe_render is not None: + safe_render = self.context.safe_render + else: + safe_render = True + + html = frappe.render_template(self.source, self.context, safe_render=safe_render) return html diff --git a/frappe/website/report/website_analytics/website_analytics.py b/frappe/website/report/website_analytics/website_analytics.py index 99fce02f1e..b4e76ba9fd 100644 --- a/frappe/website/report/website_analytics/website_analytics.py +++ b/frappe/website/report/website_analytics/website_analytics.py @@ -64,7 +64,7 @@ class WebsiteAnalytics(object): case = frappe.qb.terms.Case().when(WebPageView.is_unique == "1", "1") count_is_unique = Count(case).as_("unique_count") - query = ( + return ( frappe.qb.from_(WebPageView) .select("path", count_all, count_is_unique) .where( @@ -72,8 +72,7 @@ class WebsiteAnalytics(object): ) .groupby(WebPageView.path) .orderby("count", Order=frappe.qb.desc) - ) - return frappe.db.sql(query) + ).run() def _get_query_for_mariadb(self): filters_range = self.filters.range diff --git a/frappe/website/website_components/metatags.py b/frappe/website/website_components/metatags.py index 045bef8fe1..e26098b773 100644 --- a/frappe/website/website_components/metatags.py +++ b/frappe/website/website_components/metatags.py @@ -1,5 +1,7 @@ import frappe +METATAGS = ('title', 'description', 'image', 'author', 'published_on') + class MetaTags(): def __init__(self, path, context): self.path = path @@ -12,7 +14,7 @@ class MetaTags(): self.set_metatags_from_website_route_meta() def init_metatags_from_context(self): - for key in ('title', 'description', 'image', 'author', 'url', 'published_on'): + for key in METATAGS: if key not in self.tags and self.context.get(key): self.tags[key] = self.context[key] @@ -28,12 +30,12 @@ class MetaTags(): if "og:type" not in self.tags: self.tags["og:type"] = "article" - for key in ('title', 'description', 'image', 'author', 'url'): + for key in METATAGS: if self.tags.get(key): self.tags['og:' + key] = self.tags.get(key) def set_twitter_tags(self): - for key in ('title', 'description', 'image', 'author', 'url'): + for key in METATAGS: if self.tags.get(key): self.tags['twitter:' + key] = self.tags.get(key) diff --git a/frappe/website/workspace/website/website.json b/frappe/website/workspace/website/website.json index a2a4a299c4..8d22f84b5e 100644 --- a/frappe/website/workspace/website/website.json +++ b/frappe/website/workspace/website/website.json @@ -1,20 +1,20 @@ { - "category": "Modules", - "charts": [ - { - "chart_name": "Website Analytics" - } - ], + "category": "", + "charts": [], + "content": "[{\"type\": \"onboarding\", \"data\": {\"onboarding_name\":\"Website\", \"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Blog Post\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Blogger\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Web Page\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Web Form\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Website Settings\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Setup\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Blog\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Web Site\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Portal\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Knowledge Base\", \"col\": 4}}]", "creation": "2020-03-02 14:13:51.089373", "developer_mode_only": 0, "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", + "extends": "", "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "website", "idx": 0, - "is_standard": 1, + "is_default": 0, + "is_standard": 0, "label": "Website", "links": [ { @@ -22,6 +22,7 @@ "icon": "setting", "is_query_report": 0, "label": "Setup", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -30,6 +31,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Settings", + "link_count": 0, "link_to": "Website Settings", "link_type": "DocType", "onboard": 1, @@ -40,6 +42,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Theme", + "link_count": 0, "link_to": "Website Theme", "link_type": "DocType", "onboard": 1, @@ -50,6 +53,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Script", + "link_count": 0, "link_to": "Website Script", "link_type": "DocType", "onboard": 0, @@ -60,6 +64,7 @@ "hidden": 0, "is_query_report": 0, "label": "About Us Settings", + "link_count": 0, "link_to": "About Us Settings", "link_type": "DocType", "onboard": 0, @@ -70,6 +75,7 @@ "hidden": 0, "is_query_report": 0, "label": "Contact Us Settings", + "link_count": 0, "link_to": "Contact Us Settings", "link_type": "DocType", "onboard": 0, @@ -80,6 +86,7 @@ "icon": "", "is_query_report": 0, "label": "Blog", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -88,6 +95,7 @@ "hidden": 0, "is_query_report": 0, "label": "Blog Post", + "link_count": 0, "link_to": "Blog Post", "link_type": "DocType", "onboard": 1, @@ -98,6 +106,7 @@ "hidden": 0, "is_query_report": 0, "label": "Blogger", + "link_count": 0, "link_to": "Blogger", "link_type": "DocType", "onboard": 0, @@ -108,6 +117,7 @@ "hidden": 0, "is_query_report": 0, "label": "Blog Category", + "link_count": 0, "link_to": "Blog Category", "link_type": "DocType", "onboard": 0, @@ -118,6 +128,7 @@ "icon": "website", "is_query_report": 0, "label": "Web Site", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -126,6 +137,7 @@ "hidden": 0, "is_query_report": 0, "label": "Web Page", + "link_count": 0, "link_to": "Web Page", "link_type": "DocType", "onboard": 1, @@ -136,6 +148,7 @@ "hidden": 0, "is_query_report": 0, "label": "Web Form", + "link_count": 0, "link_to": "Web Form", "link_type": "DocType", "onboard": 1, @@ -146,6 +159,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Sidebar", + "link_count": 0, "link_to": "Website Sidebar", "link_type": "DocType", "onboard": 0, @@ -156,6 +170,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Slideshow", + "link_count": 0, "link_to": "Website Slideshow", "link_type": "DocType", "onboard": 0, @@ -166,6 +181,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Route Meta", + "link_count": 0, "link_to": "Website Route Meta", "link_type": "DocType", "onboard": 0, @@ -176,6 +192,7 @@ "icon": "website", "is_query_report": 0, "label": "Portal", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -184,6 +201,7 @@ "hidden": 0, "is_query_report": 0, "label": "Portal Settings", + "link_count": 0, "link_to": "Portal Settings", "link_type": "DocType", "onboard": 1, @@ -194,6 +212,7 @@ "icon": "project", "is_query_report": 0, "label": "Knowledge Base", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -202,6 +221,7 @@ "hidden": 0, "is_query_report": 0, "label": "Help Category", + "link_count": 0, "link_to": "Help Category", "link_type": "DocType", "onboard": 0, @@ -212,20 +232,26 @@ "hidden": 0, "is_query_report": 0, "label": "Help Article", + "link_count": 0, "link_to": "Help Article", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2020-12-01 13:38:39.556588", + "modified": "2021-08-05 12:16:03.154032", "modified_by": "Administrator", "module": "Website", "name": "Website", "onboarding": "Website", "owner": "Administrator", + "parent_page": "", "pin_to_bottom": 0, "pin_to_top": 0, + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 28, "shortcuts": [ { "color": "Green", @@ -261,5 +287,6 @@ "link_to": "Website Settings", "type": "DocType" } - ] + ], + "title": "Website" } \ No newline at end of file diff --git a/frappe/www/_test/_test_safe_render_off.html b/frappe/www/_test/_test_safe_render_off.html new file mode 100644 index 0000000000..5ad01d67d5 --- /dev/null +++ b/frappe/www/_test/_test_safe_render_off.html @@ -0,0 +1,7 @@ +--- +title: Safe Render Off +safe_render: false +--- + +
{{ title }}
+
test.__test
diff --git a/frappe/www/_test/_test_safe_render_on.html b/frappe/www/_test/_test_safe_render_on.html new file mode 100644 index 0000000000..d9f02ce081 --- /dev/null +++ b/frappe/www/_test/_test_safe_render_on.html @@ -0,0 +1,6 @@ +--- +title: Safe Render On +--- + +
{{ title }}
+
test.__test
diff --git a/package.json b/package.json index 5b9504e142..1ddbec178e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "homepage": "https://frappeframework.com", "dependencies": { + "@editorjs/editorjs": "2.20.0", "ace-builds": "^1.4.8", "air-datepicker": "github:frappe/air-datepicker", "autoprefixer": "^9.8.6", @@ -30,6 +31,7 @@ "cropperjs": "^1.5.12", "cssnano": "^5.0.0", "driver.js": "^0.9.8", + "editorjs-undo": "0.1.6", "express": "^4.17.1", "fast-deep-equal": "^2.0.1", "frappe-charts": "^2.0.0-rc13", diff --git a/yarn.lock b/yarn.lock index 11a774780e..e8f527b7f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40,6 +40,14 @@ resolved "https://registry.yarnpkg.com/@deepcode/dcignore/-/dcignore-1.0.2.tgz#39e4a3df7dde8811925330506e4bb3fbf3c288d8" integrity sha512-DPgxtHuJwBORpqRkPXzzOT+uoPRVJmaN7LR+pmeL6DQM90kj6G6GFUH1i/YpRH8NbML8ZGEDwB9f9u4UwD2pzg== +"@editorjs/editorjs@2.20.0": + version "2.20.0" + resolved "https://registry.yarnpkg.com/@editorjs/editorjs/-/editorjs-2.20.0.tgz#1e0dc7e6c1433c34c9d2d3e42153313c2dbb3514" + integrity sha512-e6DWi8bMypFhovq9R6cefaDWVfrlVU++Q7ABp79+MxZIuC/SKAW5EtxBbKPL22H/Mc3bJIhZCxOqEl70HBh2yw== + dependencies: + codex-notifier "^1.1.2" + codex-tooltip "^1.0.1" + "@nodelib/fs.scandir@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" @@ -1510,6 +1518,16 @@ code-point-at@^1.0.0: resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= +codex-notifier@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/codex-notifier/-/codex-notifier-1.1.2.tgz#a733079185f4c927fa296f1d71eb8753fe080895" + integrity sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg== + +codex-tooltip@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/codex-tooltip/-/codex-tooltip-1.0.2.tgz#81a9d3e2937658c6e5312106b47b9f094ff7be63" + integrity sha512-oC+Bu5X/zyhbPydgMSLWKoM/+vkJMqaLWu3Dt/jZgXS3MWK23INwC5DMBrVXZSufAFk0i0SUni38k9rLMyZn/w== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -2168,6 +2186,11 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +editorjs-undo@0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/editorjs-undo/-/editorjs-undo-0.1.6.tgz#823349a1e9a78d8bc68ba8570a2b854063bc804a" + integrity sha512-zVHPnBf2mcI8hWT9Eu8H3bGDEcMj4gppXbQjJW11Aa8Kdy2SVBGhM6fS59OUlBsm8iHWLxuoG2NUIfy9Rd30sw== + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -5089,9 +5112,9 @@ path-key@^3.1.0: integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== path-parse@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== path-to-regexp@0.1.7: version "0.1.7"