diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 8758c4e273..d9a6ca6f59 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -106,16 +106,14 @@ jobs: source env/bin/activate cd apps/frappe/ git remote set-url upstream https://github.com/frappe/frappe.git - git fetch --all --tags - taglist=$(git tag --sort version:refname | grep -v "beta") - last_release=$(echo "$taglist" | tail -1 | cut -d . -f 1 | cut -c 2-) - - for version in $(seq 12 "$last_release") + for version in $(seq 12 13) do - last_tag=$(echo "$taglist" | grep "v$version" | tail -1) - echo "Updating to $last_tag" - git checkout -q -f "$last_tag" + echo "Updating to v$version" + branch_name="version-$version-hotfix" + git fetch --depth 1 upstream $branch_name:$branch_name + + git checkout -q -f $branch_name pip install -q -r requirements.txt bench --site test_site migrate done diff --git a/cypress.json b/cypress.json index f2508ca66e..ae4495cfa8 100644 --- a/cypress.json +++ b/cypress.json @@ -4,6 +4,8 @@ "adminPassword": "admin", "defaultCommandTimeout": 20000, "pageLoadTimeout": 15000, + "video": true, + "videoUploadOnPasses": false, "retries": { "runMode": 2, "openMode": 2 diff --git a/cypress/integration/control_duration.js b/cypress/integration/control_duration.js index 266d421e70..09629a344f 100644 --- a/cypress/integration/control_duration.js +++ b/cypress/integration/control_duration.js @@ -33,12 +33,13 @@ context('Control Duration', () => { cy.get('@dialog').then(dialog => { let value = dialog.get_value('duration'); expect(value).to.equal(3889800); + cy.hide_dialog(); }); }); it('should hide days or seconds according to duration options', () => { get_dialog_with_duration(1, 1).as('dialog'); - cy.get('.frappe-control[data-fieldname=duration] input').first().click(); + cy.get('.frappe-control[data-fieldname=duration] input').first(); cy.get('.duration-input[data-duration=days]').should('not.be.visible'); cy.get('.duration-input[data-duration=seconds]').should('not.be.visible'); }); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 898ae1df03..6d16769b37 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -49,7 +49,7 @@ context('Control Link', () => { it('should unset invalid value', () => { get_dialog_with_link().as('dialog'); - cy.intercept('POST', '/api/method/frappe.client.validate_link*').as('validate_link'); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.get('.frappe-control[data-fieldname=link] input') .type('invalid value', { delay: 100 }) @@ -61,7 +61,7 @@ context('Control Link', () => { it('should route to form on arrow click', () => { get_dialog_with_link().as('dialog'); - cy.intercept('POST', '/api/method/frappe.client.validate_link*').as('validate_link'); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); cy.get('@todos').then(todos => { @@ -81,11 +81,11 @@ context('Control Link', () => { it('should fetch valid value', () => { cy.get('@todos').then(todos => { cy.visit(`/app/todo/${todos[0]}`); - cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value'); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input'); cy.get('@input').type('Administrator', {delay: 100}).blur(); - cy.wait('@get_value'); + cy.wait('@validate_link'); cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( 'contain', 'Administrator' ); diff --git a/cypress/integration/form.js b/cypress/integration/form.js index f860a742ef..71cc6f4f0d 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -8,11 +8,7 @@ context('Form', () => { }); it('create a new form', () => { cy.visit('/app/todo/new'); - cy.get('[data-fieldname="description"] .ql-editor') - .first() - .click() - .type('this is a test todo'); - cy.wait(300); + cy.get_field('description', 'Text Editor').type('this is a test todo', {force: true}).wait(200); cy.get('.page-title').should('contain', 'Not Saved'); cy.intercept({ method: 'POST', @@ -20,29 +16,34 @@ context('Form', () => { }).as('form_save'); cy.get('.primary-action').click(); cy.wait('@form_save').its('response.statusCode').should('eq', 200); + cy.visit('/app/todo'); - cy.wait(300); - cy.get('.title-text').should('be.visible').and('contain', 'To Do'); + cy.get('.page-head').findByTitle('To Do').should('exist'); cy.get('.list-row').should('contain', 'this is a test todo'); }); + it('navigates between documents with child table list filters applied', () => { cy.visit('/app/contact'); - cy.add_filter(); - cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true }); - cy.findByRole('button', {name: 'Apply Filters'}).click({ force: true }); - cy.visit('/app/contact/Test Form Contact 3'); + + cy.clear_filters(); + cy.get('.standard-filter-section [data-fieldname="name"] input').type('Test Form Contact 3').blur(); + cy.click_listview_row_item(0); + cy.get('.prev-doc').should('be.visible').click(); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); cy.hide_dialog(); - cy.get('.next-doc').click(); - cy.wait(200); + + cy.get('.next-doc').should('be.visible').click(); + cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); cy.hide_dialog(); - cy.contains('Test Form Contact 2').should('not.exist'); - cy.get('.title-text').should('contain', 'Test Form Contact 3'); + + cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist'); + // clear filters cy.visit('/app/contact'); cy.clear_filters(); }); + it('validates behaviour of Data options validations in child table', () => { // test email validations for set_invalid controller let website_input = 'website.in'; diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js index c07230d2b8..84b3320282 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -13,7 +13,7 @@ context('Grid Pagination', () => { it('creates pages for child table', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); - cy.get('@table').find('.current-page-number').should('contain', '1'); + cy.get('@table').find('.current-page-number').should('have.value', '1'); cy.get('@table').find('.total-page-number').should('contain', '20'); cy.get('@table').find('.grid-body .grid-row').should('have.length', 50); }); @@ -21,10 +21,10 @@ context('Grid Pagination', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('.next-page').click(); - cy.get('@table').find('.current-page-number').should('contain', '2'); + cy.get('@table').find('.current-page-number').should('have.value', '2'); cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '51'); cy.get('@table').find('.prev-page').click(); - cy.get('@table').find('.current-page-number').should('contain', '1'); + cy.get('@table').find('.current-page-number').should('have.value', '1'); cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '1'); }); it('adds and deletes rows and changes page', () => { @@ -32,14 +32,35 @@ context('Grid Pagination', () => { cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); 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('.current-page-number').should('have.value', '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').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('.current-page-number').should('have.value', '20'); cy.get('@table').find('.total-page-number').should('contain', '20'); }); + it('go to specific page, use up and down arrow, type characters, 0 page and more than existing page', () => { + cy.visit('/app/contact/Test Contact'); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + cy.get('@table').find('.current-page-number').focus().clear().type('17').blur(); + cy.get('@table').find('.grid-body .row-index').should('contain', 801); + + cy.get('@table').find('.current-page-number').focus().type('{uparrow}{uparrow}'); + cy.get('@table').find('.current-page-number').should('have.value', '19'); + + cy.get('@table').find('.current-page-number').focus().type('{downarrow}{downarrow}'); + cy.get('@table').find('.current-page-number').should('have.value', '17'); + + cy.get('@table').find('.current-page-number').focus().clear().type('700').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '20'); + + cy.get('@table').find('.current-page-number').focus().clear().type('0').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '1'); + + cy.get('@table').find('.current-page-number').focus().clear().type('abc').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '1'); + }); // it('deletes all rows', ()=> { // cy.visit('/app/contact/Test Contact'); // cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index ce9e87274b..7791bef8f5 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -13,8 +13,7 @@ context('List View', () => { cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click(); cy.get('.dropdown-menu li:visible .dropdown-item .menu-item-label[data-label="Edit"]').click(); - cy.get('.modal-body .form-control[data-fieldname="field"]').first().select('Due Date').wait(200); - cy.fill_field('value', '09-28-21', 'Date'); + cy.get('.modal-body .form-control[data-fieldname="field"]').first().select('Priority').wait(200); cy.get('.modal-footer .standard-actions .btn-primary').click(); cy.wait(500); diff --git a/cypress/integration/navigation.js b/cypress/integration/navigation.js index c4d0638f26..b4e023c53e 100644 --- a/cypress/integration/navigation.js +++ b/cypress/integration/navigation.js @@ -13,10 +13,10 @@ context('Navigation', () => { it.only('Navigate to previous page after login', () => { cy.visit('/app/todo'); - cy.findByTitle('To Do').should('be.visible'); + cy.get('.page-head').findByTitle('To Do').should('be.visible'); cy.request('/api/method/logout'); - cy.reload(); - cy.get('.btn-primary').contains('Login').click(); + cy.reload().as('reload'); + cy.get('@reload').get('.page-card .btn-primary').contains('Login').click(); cy.location('pathname').should('eq', '/login'); cy.login(); cy.visit('/app'); diff --git a/cypress/integration/query_report.js b/cypress/integration/query_report.js index 9bd542977d..43f26f8b50 100644 --- a/cypress/integration/query_report.js +++ b/cypress/integration/query_report.js @@ -8,6 +8,10 @@ context('Query Report', () => { 'report_type': 'Query Report', 'query': 'select * from tabToDo' }, true).as('doc'); + cy.create_records({ + doctype: 'ToDo', + description: 'this is a test todo for query report' + }).as('todos'); }); it('add custom column in report', () => { diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js index caf1349e6e..7d4c83abf5 100644 --- a/cypress/integration/recorder.js +++ b/cypress/integration/recorder.js @@ -14,48 +14,51 @@ context('Recorder', () => { }); it('Recorder Empty State', () => { - cy.findByTitle('Recorder').should('exist'); + cy.get('.page-head').findByTitle('Recorder').should('exist'); cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red'); - cy.findByRole('button', {name: 'Start'}).should('exist'); - cy.findByRole('button', {name: 'Clear'}).should('exist'); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).should('exist'); + cy.get('.page-actions').findByRole('button', {name: 'Clear'}).should('exist'); - cy.get('.msg-box').should('contain', 'Inactive'); - cy.findByRole('button', {name: 'Start Recording'}).should('exist'); + cy.get('.msg-box').should('contain', 'Recorder is Inactive'); + cy.get('.msg-box').findByRole('button', {name: 'Start Recording'}).should('exist'); }); it('Recorder Start', () => { - cy.findByRole('button', {name: 'Start'}).click(); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).click(); cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green'); - cy.get('.msg-box').should('contain', 'No Requests'); + cy.get('.msg-box').should('contain', 'No Requests found'); cy.visit('/app/List/DocType/List'); cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); cy.wait('@list_refresh'); - cy.get('.title-text').should('contain', 'DocType'); + cy.get('.page-head').findByTitle('DocType').should('exist'); cy.get('.list-count').should('contain', '20 of '); cy.visit('/app/recorder'); - cy.findByTitle('Recorder').should('exist'); - cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get'); + cy.get('.page-head').findByTitle('Recorder').should('exist'); + cy.get('.frappe-list .result-list').should('contain', '/api/method/frappe.desk.reportview.get'); }); it('Recorder View Request', () => { - cy.findByRole('button', {name: 'Start'}).click(); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).click(); cy.visit('/app/List/DocType/List'); cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); cy.wait('@list_refresh'); - cy.get('.title-text').should('contain', 'DocType'); + cy.get('.page-head').findByTitle('DocType').should('exist'); cy.get('.list-count').should('contain', '20 of '); cy.visit('/app/recorder'); - cy.get('.list-row-container span').contains('/api/method/frappe').click(); + cy.get('.frappe-list .list-row-container span') + .contains('/api/method/frappe') + .should('be.visible') + .click({force: true}); cy.url().should('include', '/recorder/request'); cy.get('form').should('contain', '/api/method/frappe'); diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index e762eebea1..0253e8fd43 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -23,8 +23,7 @@ context('Report View', () => { let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); // select the cell cell.dblclick(); - cell.findByRole('checkbox').check({ force: true }); - cy.get('.dt-row-0 > .dt-cell--col-5').click(); + cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true }); cy.wait('@value-update'); cy.get('@doc').then(doc => { cy.call('frappe.client.get_value', { diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index 191b5a2b2c..6c4733400d 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -8,22 +8,18 @@ context('Timeline', () => { it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => { //Adding new ToDo + cy.visit('/app/todo/new-todo-1'); + cy.get('[data-fieldname="description"] .ql-editor.ql-blank').type('Test ToDo', {force: true}).wait(200); + cy.get('.page-head .page-actions').findByRole('button', {name: 'Save'}).click(); + cy.visit('/app/todo'); - cy.click_listview_primary_button('Add ToDo'); - cy.findByRole('button', {name: 'Edit in full page'}).click(); - cy.findByTitle('New ToDo').should('be.visible'); - cy.get('[data-fieldname="description"] .ql-editor').eq(0).type('Test ToDo', {force: true}); - cy.wait(200); - cy.findByRole('button', {name: 'Save'}).click(); - cy.wait(700); - cy.visit('/app/todo'); - cy.get('.level-item.ellipsis').eq(0).click(); + cy.click_listview_row_item(0); //To check if the comment box is initially empty and tying some text into it cy.get('[data-fieldname="comment"] .ql-editor').should('contain', '').type('Testing Timeline'); //Adding new comment - cy.findByRole('button', {name: 'Comment'}).click(); + cy.get('.comment-box').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'); @@ -38,21 +34,17 @@ context('Timeline', () => { //Discarding comment cy.click_timeline_action_btn("Edit"); - cy.findByRole('button', {name: 'Dismiss'}).click(); + cy.click_timeline_action_btn("Dismiss"); //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('.more-actions > .action-btn').click(); - cy.get('.more-actions .dropdown-item').contains('Delete').click(); - cy.findByRole('button', {name: 'Yes'}).click(); - cy.click_modal_primary_button('Yes'); + cy.get('.timeline-message-box .more-actions > .action-btn').click(); //Menu button in timeline item + cy.get('.timeline-message-box .more-actions .dropdown-item').contains('Delete').click({ force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click({ force: true }); - //Deleting the added ToDo - cy.get('[id="page-ToDo"] .menu-btn-group [data-original-title="Menu"]').click(); - cy.get('[id="page-ToDo"] .menu-btn-group .dropdown-item').contains('Delete').click(); - cy.findByRole('button', {name: 'Yes'}).click(); + cy.get('.timeline-content').should('not.contain', 'Testing Timeline 123'); }); it('Timeline should have submit and cancel activity information', () => { @@ -66,31 +58,32 @@ context('Timeline', () => { //Adding a new entry for the created custom doctype cy.fill_field('title', 'Test'); - cy.findByRole('button', {name: 'Save'}).click(); - cy.findByRole('button', {name: 'Submit'}).click(); + cy.click_modal_primary_button('Save'); + cy.click_modal_primary_button('Submit'); + cy.visit('/app/custom-submittable-doctype'); - cy.get('.list-subject > .bold > .ellipsis').eq(0).click(); + cy.click_listview_row_item(0); //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.findByRole('button', {name: 'Cancel'}).click({delay: 900}); - cy.findByRole('button', {name: 'Yes'}).click(); + cy.get('[id="page-Custom Submittable DocType"] .page-actions').findByRole('button', {name: 'Cancel'}).click(); + cy.get_open_dialog().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.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .dropdown-item').contains("Delete").click(); - cy.click_modal_primary_button('Yes', {force: true, delay: 700}); + cy.select_listview_row_checkbox(0); + cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click(); + cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click(); + cy.click_modal_primary_button('Yes'); //Deleting the custom doctype cy.visit('/app/doctype'); - cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.select_listview_row_checkbox(0); + cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click(); + cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click(); cy.click_modal_primary_button('Yes'); }); }); \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 64a3b18b2f..933f6a1758 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -341,7 +341,7 @@ Cypress.Commands.add('click_sidebar_button', (btn_name) => { }); Cypress.Commands.add('click_listview_row_item', (row_no) => { - cy.get('.list-row > .level-left > .list-subject > .bold > .ellipsis').eq(row_no).click({force: true}); + cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis').eq(row_no).click({force: true}); }); Cypress.Commands.add('click_filter_button', () => { @@ -353,5 +353,9 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => { }); Cypress.Commands.add('click_timeline_action_btn', (btn_name) => { - cy.get('.timeline-message-box .custom-actions > .btn').contains(btn_name).click(); + cy.get('.timeline-message-box .actions .action-btn').contains(btn_name).click(); +}); + +Cypress.Commands.add('select_listview_row_checkbox', (row_no) => { + cy.get('.frappe-list .select-like > .list-row-checkbox').eq(row_no).click(); }); diff --git a/dev-requirements.txt b/dev-requirements.txt index df3ae9484a..f4045c6bed 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,4 @@ +coverage==5.5 Faker~=8.1.0 pyngrok~=5.0.5 unittest-xml-reporting~=3.0.4 diff --git a/esbuild/build-cleanup.js b/esbuild/build-cleanup.js new file mode 100644 index 0000000000..cf03606a34 --- /dev/null +++ b/esbuild/build-cleanup.js @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +const path = require("path"); +const fs = require("fs"); +const glob = require("fast-glob"); + +module.exports = { + name: 'build_cleanup', + setup(build) { + build.onEnd(result => { + if (result.errors.length) return; + clean_dist_files(Object.keys(result.metafile.outputs)); + }); + }, +}; + +function clean_dist_files(new_files) { + new_files.forEach( + file => { + if (file.endsWith(".map")) return; + + const pattern = file.split(".").slice(0, -2).join(".") + "*"; + glob.sync(pattern).forEach( + file_to_delete => { + if (file_to_delete.startsWith(file)) return; + + fs.unlink(path.resolve(file_to_delete), err => { + if (!err) return; + + console.error( + `Error deleting ${file.split(path.sep).pop()}` + ); + }); + } + + ); + } + ); +} \ No newline at end of file diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 18de95b40d..792cb56198 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -1,18 +1,20 @@ /* eslint-disable no-console */ -let path = require("path"); -let fs = require("fs"); -let glob = require("fast-glob"); -let esbuild = require("esbuild"); -let vue = require("esbuild-vue"); -let yargs = require("yargs"); -let cliui = require("cliui")(); -let chalk = require("chalk"); -let html_plugin = require("./frappe-html"); -let rtlcss = require('rtlcss'); -let postCssPlugin = require("esbuild-plugin-postcss2").default; -let ignore_assets = require("./ignore-assets"); -let sass_options = require("./sass_options"); -let { +const path = require("path"); +const fs = require("fs"); +const glob = require("fast-glob"); +const esbuild = require("esbuild"); +const vue = require("esbuild-vue"); +const yargs = require("yargs"); +const cliui = require("cliui")(); +const chalk = require("chalk"); +const html_plugin = require("./frappe-html"); +const rtlcss = require('rtlcss'); +const postCssPlugin = require("esbuild-plugin-postcss2").default; +const ignore_assets = require("./ignore-assets"); +const sass_options = require("./sass_options"); +const build_cleanup_plugin = require("./build-cleanup"); + +const { app_list, assets_path, apps_path, @@ -26,7 +28,7 @@ let { get_redis_subscriber } = require("./utils"); -let argv = yargs +const argv = yargs .usage("Usage: node esbuild [options]") .option("apps", { type: "string", @@ -98,9 +100,6 @@ if (WATCH_MODE) { async function execute() { console.time(TOTAL_BUILD_TIME); - if (!FILES_TO_BUILD.length) { - await clean_dist_folders(APPS); - } let results; try { @@ -231,12 +230,13 @@ function get_files_to_build(files) { function build_files({ files, outdir }) { let build_plugins = [ html_plugin, + build_cleanup_plugin, vue(), ]; return esbuild.build(get_build_options(files, outdir, build_plugins)); } -function build_style_files({ files, outdir, rtl_style=false }) { +function build_style_files({ files, outdir, rtl_style = false }) { let plugins = []; if (rtl_style) { plugins.push(rtlcss); @@ -244,6 +244,7 @@ function build_style_files({ files, outdir, rtl_style=false }) { let build_plugins = [ ignore_assets, + build_cleanup_plugin, postCssPlugin({ plugins: plugins, sassOptions: sass_options @@ -313,24 +314,6 @@ function get_watch_config() { return null; } -async function clean_dist_folders(apps) { - for (let app of apps) { - let public_path = get_public_path(app); - let paths = [ - path.resolve(public_path, "dist", "js"), - path.resolve(public_path, "dist", "css"), - path.resolve(public_path, "dist", "css-rtl") - ]; - for (let target of paths) { - if (fs.existsSync(target)) { - // rmdir is deprecated in node 16, this will work in both node 14 and 16 - let rmdir = fs.promises.rm || fs.promises.rmdir; - await rmdir(target, { recursive: true }); - } - } - } -} - function log_built_assets(results) { let outputs = {}; for (const result of results) { diff --git a/frappe/client.py b/frappe/client.py index bd331168c2..a3ed0fa37d 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -87,7 +87,7 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren filters = {"name": filters} try: - fields = json.loads(fieldname) + fields = frappe.parse_json(fieldname) except (TypeError, ValueError): # name passed, not json fields = [fieldname] @@ -407,7 +407,7 @@ def is_document_amended(doctype, docname): return False @frappe.whitelist() -def validate_link(doctype: str, docname: str): +def validate_link(doctype: str, docname: str, fields=None): if not isinstance(doctype, str): frappe.throw(_("DocType must be a string")) @@ -424,4 +424,26 @@ def validate_link(doctype: str, docname: str): frappe.PermissionError ) - return frappe.db.get_value(doctype, docname, cache=True) + values = frappe._dict() + values.name = frappe.db.get_value(doctype, docname, cache=True) + + fields = frappe.parse_json(fields) + if not values.name or not fields: + return values + + try: + values.update(get_value(doctype, fields, docname)) + except frappe.PermissionError: + frappe.clear_last_message() + frappe.msgprint( + _("You need {0} permission to fetch values from {1} {2}") + .format( + frappe.bold(_("Read")), + frappe.bold(doctype), + frappe.bold(docname) + ), + title=_("Cannot Fetch Values"), + indicator="orange" + ) + + return values diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 416f014164..e311b8db6a 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -723,7 +723,7 @@ def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=Fals frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile") # run for headless mode - run_or_open = 'run --browser firefox --record' if headless else 'open' + run_or_open = 'run --browser chrome --record' if headless else 'open' formatted_command = f'{site_env} {password_env} {coverage_env} {cypress_path} {run_or_open}' if parallel: diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py index 99bd19c106..cd9af498aa 100644 --- a/frappe/core/doctype/comment/test_comment.py +++ b/frappe/core/doctype/comment/test_comment.py @@ -5,6 +5,15 @@ import frappe, json import unittest class TestComment(unittest.TestCase): + def tearDown(self): + frappe.form_dict.comment = None + frappe.form_dict.comment_email = None + frappe.form_dict.comment_by = None + frappe.form_dict.reference_doctype = None + frappe.form_dict.reference_name = None + frappe.form_dict.route = None + frappe.local.request_ip = None + def test_comment_creation(self): test_doc = frappe.get_doc(dict(doctype = 'ToDo', description = 'test')) test_doc.insert() @@ -33,8 +42,16 @@ class TestComment(unittest.TestCase): frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) from frappe.templates.includes.comments.comments import add_comment - add_comment('Good comment with 10 chars', 'test@test.com', 'Good Tester', - 'Blog Post', test_blog.name, test_blog.route) + + frappe.form_dict.comment = 'Good comment with 10 chars' + frappe.form_dict.comment_email = 'test@test.com' + frappe.form_dict.comment_by = 'Good Tester' + frappe.form_dict.reference_doctype = 'Blog Post' + frappe.form_dict.reference_name = test_blog.name + frappe.form_dict.route = test_blog.route + frappe.local.request_ip = '127.0.0.1' + + add_comment() self.assertEqual(frappe.get_all('Comment', fields = ['*'], filters = dict( reference_doctype = test_blog.doctype, @@ -43,8 +60,10 @@ class TestComment(unittest.TestCase): frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) - add_comment('pleez vizits my site http://mysite.com', 'test@test.com', 'bad commentor', - 'Blog Post', test_blog.name, test_blog.route) + frappe.form_dict.comment = 'pleez vizits my site http://mysite.com' + frappe.form_dict.comment_by = 'bad commentor' + + add_comment() self.assertEqual(len(frappe.get_all('Comment', fields = ['*'], filters = dict( reference_doctype = test_blog.doctype, diff --git a/frappe/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json index b77e7a6677..f8380cfda6 100644 --- a/frappe/core/doctype/feedback/feedback.json +++ b/frappe/core/doctype/feedback/feedback.json @@ -8,34 +8,14 @@ "reference_doctype", "reference_name", "column_break_3", - "rating", - "ip_address", - "section_break_6", - "feedback" + "like", + "ip_address" ], "fields": [ { "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "fieldname": "rating", - "fieldtype": "Float", - "in_list_view": 1, - "label": "Rating", - "precision": "1", - "reqd": 1 - }, - { - "fieldname": "section_break_6", - "fieldtype": "Section Break" - }, - { - "fieldname": "feedback", - "fieldtype": "Small Text", - "label": "Feedback", - "reqd": 1 - }, { "fieldname": "reference_doctype", "fieldtype": "Select", @@ -57,11 +37,17 @@ "hidden": 1, "label": "IP Address", "read_only": 1 + }, + { + "default": "0", + "fieldname": "like", + "fieldtype": "Check", + "label": "Like" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-06-23 12:45:42.045696", + "modified": "2021-11-10 20:53:21.255593", "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 f3cf8dfe6b..66f644ccd3 100644 --- a/frappe/core/doctype/feedback/test_feedback.py +++ b/frappe/core/doctype/feedback/test_feedback.py @@ -8,8 +8,7 @@ class TestFeedback(unittest.TestCase): def tearDown(self): frappe.form_dict.reference_doctype = None frappe.form_dict.reference_name = None - frappe.form_dict.rating = None - frappe.form_dict.feedback = None + frappe.form_dict.like = None frappe.local.request_ip = None def test_feedback_creation_updation(self): @@ -18,23 +17,22 @@ class TestFeedback(unittest.TestCase): frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) - from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback + from frappe.templates.includes.feedback.feedback import give_feedback frappe.form_dict.reference_doctype = 'Blog Post' frappe.form_dict.reference_name = test_blog.name - frappe.form_dict.rating = 5 - frappe.form_dict.feedback = 'New feedback' + frappe.form_dict.like = True frappe.local.request_ip = '127.0.0.1' - feedback = add_feedback() + feedback = give_feedback() - self.assertEqual(feedback.feedback, 'New feedback') - self.assertEqual(feedback.rating, 5) + self.assertEqual(feedback.like, True) - updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback') + frappe.form_dict.like = False - self.assertEqual(updated_feedback.feedback, 'Updated feedback') - self.assertEqual(updated_feedback.rating, 6) + updated_feedback = give_feedback() + + self.assertEqual(updated_feedback.like, False) frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) diff --git a/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py index 375ea02e0e..dc17526047 100644 --- a/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py +++ b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py @@ -2,9 +2,10 @@ import frappe from ..role import desk_properties def execute(): + frappe.reload_doctype('user') frappe.reload_doctype('role') for role in frappe.get_all('Role', ['name', 'desk_access']): role_doc = frappe.get_doc('Role', role.name) for key in desk_properties: role_doc.set(key, role_doc.desk_access) - role_doc.save() \ No newline at end of file + role_doc.save() diff --git a/frappe/patches/v13_0/rename_custom_client_script.py b/frappe/patches/v13_0/rename_custom_client_script.py index 718f1f6a46..b74c518aeb 100644 --- a/frappe/patches/v13_0/rename_custom_client_script.py +++ b/frappe/patches/v13_0/rename_custom_client_script.py @@ -1,9 +1,13 @@ import frappe +from frappe.model.rename_doc import rename_doc def execute(): if frappe.db.exists("DocType", "Client Script"): return - frappe.rename_doc("DocType", "Custom Script", "Client Script") + frappe.flags.ignore_route_conflict_validation = True + rename_doc("DocType", "Custom Script", "Client Script") + frappe.flags.ignore_route_conflict_validation = False + frappe.reload_doctype("Client Script", force=True) diff --git a/frappe/public/icons/timeless/message.svg b/frappe/public/icons/timeless/message.svg index f63327f5e6..b056762802 100644 --- a/frappe/public/icons/timeless/message.svg +++ b/frappe/public/icons/timeless/message.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 6a14637f33..58442ec371 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -167,10 +167,12 @@ frappe.ui.form.Control = class BaseControl { } this.inside_change_event = true; - var set = function(value) { + function set(value) { me.inside_change_event = false; return frappe.run_serially([ + () => me._validated = true, () => me.set_model_value(value), + () => delete me._validated, () => { me.set_mandatory && me.set_mandatory(value); diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index aa3753e67f..7818e7fd96 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -444,7 +444,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } validate(value) { // validate the value just entered - if(this.df.options=="[Select]" || this.df.ignore_link_validation) { + if ( + this._validated + || this.df.options=="[Select]" + || this.df.ignore_link_validation + ) { return value; } @@ -454,39 +458,33 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat validate_link_and_fetch(df, options, docname, value) { if (!value) return; - return new Promise(async (resolve) => { - const fetch_map = this.fetch_map; - const columns_to_fetch = Object.values(fetch_map); + const fetch_map = this.fetch_map; + const columns_to_fetch = Object.values(fetch_map); - // if default and no fetch, no need to validate - if (!columns_to_fetch.length && df.__default_value === value) { - return resolve(value); + // if default and no fetch, no need to validate + if (!columns_to_fetch.length && df.__default_value === value) { + return value; + } + + return frappe.xcall("frappe.client.validate_link", { + doctype: options, + docname: value, + fields: columns_to_fetch, + }).then((response) => { + if (!response || !response.name) return ""; + if (!docname || !columns_to_fetch.length) return response.name; + + for (const [target_field, source_field] of Object.entries(fetch_map)) { + frappe.model.set_value( + df.parent, + docname, + target_field, + response[source_field], + df.fieldtype, + ); } - const name = await frappe.xcall("frappe.client.validate_link", { - doctype: options, - docname: value - }); - - if (!name) return resolve(""); - if (!docname || !columns_to_fetch.length) return resolve(name); - - frappe.db.get_value( - options, - value, - columns_to_fetch, - (response) => { - for (const [target_field, source_field] of Object.entries(fetch_map)) { - frappe.model.set_value( - df.parent, - docname, - target_field, - response[source_field], - df.fieldtype, - ); - } - } - ).always(() => resolve(name)); + return response.name; }); } diff --git a/frappe/public/js/frappe/form/grid_pagination.js b/frappe/public/js/frappe/form/grid_pagination.js index 35daafe89d..76a5f7b50b 100644 --- a/frappe/public/js/frappe/form/grid_pagination.js +++ b/frappe/public/js/frappe/form/grid_pagination.js @@ -46,8 +46,55 @@ export default class GridPagination { this.last_page_button.on('click', () => { this.go_to_page(this.total_pages); }); + + this.$page_number.on('keyup', (e) => { + e.currentTarget.style.width = ((e.currentTarget.value.length + 1) * 8) + 'px'; + }); + + this.$page_number.on('keydown', (e) => { + e = (e) ? e : window.event; + var charCode = (e.which) ? e.which : e.keyCode; + let arrow = { up: 38, down: 40 }; + + switch (charCode) { + case arrow.up: + this.inc_dec_number(true); + break; + case arrow.down: + this.inc_dec_number(false); + break; + } + + // only allow numbers from 0-9 and up, down, left, right arrow keys + if (charCode > 31 && (charCode < 48 || charCode > 57) && + ![37, 38, 39, 40].includes(charCode)) { + return false; + } + return true; + }); + + this.$page_number.on('focusout', (e) => { + if (this.page_index == e.currentTarget.value) return; + this.page_index = e.currentTarget.value; + + if (this.page_index < 1) { + this.page_index = 1; + } else if (this.page_index > this.total_pages) { + this.page_index = this.total_pages; + } + + this.go_to_page(); + }); } + inc_dec_number(increment) { + let new_value = parseInt(this.$page_number.val()); + increment ? new_value++ : new_value--; + + if (new_value < 1 || new_value > this.total_pages) return; + + this.$page_number.val(new_value); + } update_page_numbers() { let total_pages = Math.ceil(this.grid.data.length/this.page_length); @@ -65,7 +112,7 @@ export default class GridPagination { get_pagination_html() { let page_text_html = `
- ${__(this.page_index)} + ${__('of')} ${__(this.total_pages)}
`; @@ -104,7 +151,8 @@ export default class GridPagination { let $rows = $(this.grid.parent).find(".rows").empty(); this.grid.render_result_rows($rows, true); if (this.$page_number) { - this.$page_number.text(index); + this.$page_number.val(index); + this.$page_number.css('width', ((index.toString().length + 1) * 8) + 'px'); } this.update_page_numbers(); diff --git a/frappe/public/js/frappe/utils/user.js b/frappe/public/js/frappe/utils/user.js index aeeb83f630..3eb73b21e5 100644 --- a/frappe/public/js/frappe/utils/user.js +++ b/frappe/public/js/frappe/utils/user.js @@ -12,7 +12,7 @@ frappe.user_info = function(uid) { if(!(frappe.boot.user_info && frappe.boot.user_info[uid])) { var user_info = { - fullname: frappe.utils.capitalize(uid.split("@")[0]) || "Unknown" + fullname: frappe.utils.to_title_case(uid.split("@")[0]) || "Unknown" }; } else { var user_info = frappe.boot.user_info[uid]; diff --git a/frappe/public/js/frappe/views/interaction.js b/frappe/public/js/frappe/views/interaction.js index 119eba13fb..a1f5947e11 100644 --- a/frappe/public/js/frappe/views/interaction.js +++ b/frappe/public/js/frappe/views/interaction.js @@ -224,6 +224,9 @@ frappe.views.InteractionComposer = class InteractionComposer { if (!("owner" in interaction_values)){ interaction_values["owner"] = frappe.session.user; } + if (!("assigned_by" in interaction_values) && interaction_values["doctype"] == "ToDo") { + interaction_values["assigned_by"] = frappe.session.user; + } return frappe.call({ method:"frappe.client.insert", args: { doc: interaction_values}, diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss index 57d0583b35..3014211222 100644 --- a/frappe/public/scss/common/grid.scss +++ b/frappe/public/scss/common/grid.scss @@ -373,6 +373,18 @@ .page-text { display: inline-block; + cursor: default; +} + +.current-page-number { + width: 16px; + text-align: center; + border: none; + cursor: text; + + &:focus { + outline: none; + } } .prev-page, diff --git a/frappe/public/scss/website/blog.scss b/frappe/public/scss/website/blog.scss index ea82efed21..e599210435 100644 --- a/frappe/public/scss/website/blog.scss +++ b/frappe/public/scss/website/blog.scss @@ -1,3 +1,8 @@ +:root { + --comment-timeline-bottom: 60px; + --comment-timeline-top: 8px; +} + .blog-list { display: flex; flex-wrap: wrap; @@ -96,4 +101,124 @@ margin-top: 3rem; } } + + + .feedback-item svg { + vertical-align: sub; + } + + .blog-feedback { + display: flex; + + .like-icon { + cursor: pointer; + + &.gray use { + fill: var(--gray-600); + stroke: none; + } + } + } + + .add-comment-button { + margin-left: 35px; + } + + .timeline-dot { + width: 16px; + height: 16px; + border-radius: 50%; + position: absolute; + top: 8px; + left: 22px; + background-color: var(--fg-color); + border: 1px solid var(--dark-border-color); + + &:before { + content: ' '; + background: var(--gray-600); + position: absolute; + top: 5px; + left: 5px; + border-radius: 50%; + height: 4px; + width: 4px; + } + } + + .blog-comments { + .comment-form-wrapper { + display: none; + } + + .add-comment-section { + .login-required { + padding: var(--padding-sm); + border-radius: var(--border-radius-sm); + box-shadow: var(--card-shadow); + } + + .new-comment { + display: flex; + padding: var(--padding-lg); + box-shadow: var(--card-shadow); + border-radius: var(--border-radius-md); + + .new-comment-fields { + flex: 1; + + .form-label { + font-weight: var(--text-bold); + } + + .comment-text-area textarea { + resize: none; + } + + @media (min-width: 576px) { + .comment-by { + padding-right: 0px !important; + padding-bottom: 0px !important; + } + } + } + } + } + + + #comment-list { + position: relative; + padding-left: var(--padding-xl); + + &:before { + content: " "; + position: absolute; + top: var(--comment-timeline-top); + bottom: var(--comment-timeline-bottom); + border-left: 1px solid var(--dark-border-color); + } + + .comment-row { + position: relative; + + .comment-avatar { + position: absolute; + top: 10px; + left: -17px; + } + + .comment-content { + box-shadow: var(--card-shadow); + border-radius: var(--border-radius-md); + padding: var(--padding-md); + margin-left: 35px; + flex: 1; + + .content p{ + margin-bottom: 0px; + } + } + } + } + } } diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss index e5e9fe95c6..2957a0b499 100644 --- a/frappe/public/scss/website/index.scss +++ b/frappe/public/scss/website/index.scss @@ -99,6 +99,12 @@ } } +.page-header-wrapper { + display: flex; + justify-content: space-between; + align-items: center; +} + .breadcrumb-container { margin-top: 1rem; padding-top: 0.25rem; @@ -256,3 +262,13 @@ h5.modal-title { .about-footer { padding-top: 1rem; } + +.login-content.container { + background-color: var(--fg-color); + padding: 45px 0px; + box-shadow: var(--shadow-base); + border-radius: var(--border-radius-md); + max-width: 400px; + margin: 70px auto; + font-size: $font-size-sm; +} diff --git a/frappe/templates/discussions/button.html b/frappe/templates/discussions/button.html index 597fb1476d..8e61d2412f 100644 --- a/frappe/templates/discussions/button.html +++ b/frappe/templates/discussions/button.html @@ -1,7 +1,9 @@ {% if frappe.session.user != "Guest" and (condition is not defined or (condition is defined and condition )) %} - + {{ _(cta_title) }} - + + {% endif %} diff --git a/frappe/templates/discussions/comment_box.html b/frappe/templates/discussions/comment_box.html index ab4714185a..ba8f440ad4 100644 --- a/frappe/templates/discussions/comment_box.html +++ b/frappe/templates/discussions/comment_box.html @@ -28,7 +28,7 @@ {{ _("Cancel") }} -
+
{{ _("Post") }}
diff --git a/frappe/templates/discussions/discussions_section.html b/frappe/templates/discussions/discussions_section.html index 57dc59c926..07c229595b 100644 --- a/frappe/templates/discussions/discussions_section.html +++ b/frappe/templates/discussions/discussions_section.html @@ -8,16 +8,16 @@ {% include "frappe/templates/discussions/topic_modal.html" %}
- {{ _(title) }} + {{ _(title) }} {% if topics and not single_thread %} {% include "frappe/templates/discussions/button.html" %} {% endif %}
-
+
{% if topics and not single_thread %} -
{% include "frappe/templates/discussions/search.html" %} @@ -38,24 +38,23 @@ {% include "frappe/templates/discussions/reply_section.html" %} {% else %} -
-
No {{ title }}
-
There are no {{ title | lower }} for this {{ doctype | lower }}, why don't you start - one!
+
+ +
{{ empty_state_title }}
+
{{ empty_state_subtitle }}
{% if frappe.session.user == "Guest" %} -
{{ _("Log In") }}
+
{{ _("Login") }}
{% elif condition is defined and not condition %} - + {% else %} {% include "frappe/templates/discussions/button.html" %} {% endif %}
+ {% endif %}
-{% endif %} -
{% block script %} diff --git a/frappe/templates/discussions/reply_section.html b/frappe/templates/discussions/reply_section.html index c0be40ab01..b269883ba0 100644 --- a/frappe/templates/discussions/reply_section.html +++ b/frappe/templates/discussions/reply_section.html @@ -9,13 +9,13 @@ {% if topic %} id="t{{ topic.name }}" data-topic="{{ topic.name }}" {% endif %}> {% if not single_thread %} -
+
{{ _("Back") }}
{% endif %} {% if topic and topic.title %} -
{{ topic.title }}
+
{{ topic.title }}
{% endif %} {% for reply in replies %} @@ -30,9 +30,9 @@
{{ _("Want to join the discussion?") }} {% if frappe.session.user == "Guest" %} -
{{ _("Log In") }}
+
{{ _("Login") }}
{% elif not condition %} -
{{ button_name }} +
{{ button_name }}
{% endif %}
diff --git a/frappe/templates/discussions/sidebar.html b/frappe/templates/discussions/sidebar.html index 764d88e0bb..14d38c86a5 100644 --- a/frappe/templates/discussions/sidebar.html +++ b/frappe/templates/discussions/sidebar.html @@ -3,12 +3,12 @@
{{ topic.title }}