diff --git a/.editorconfig b/.editorconfig index 24f122a8d4..d76f67cd7f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,6 @@ trim_trailing_whitespace = true charset = utf-8 # python, js indentation settings -[{*.py,*.js}] +[{*.py,*.js,*.vue}] indent_style = tab indent_size = 4 diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh index 9be8519d85..d16f5b62ad 100644 --- a/.github/helper/install_dependencies.sh +++ b/.github/helper/install_dependencies.sh @@ -2,11 +2,6 @@ set -e -# python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" -# if [[ $? != 2 ]];then -# exit; -# fi - # install wkhtmltopdf wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz tar -xf /tmp/wkhtmltox.tar.xz -C /tmp diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index ea4f07b9f7..9831df7f30 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -1,56 +1,72 @@ -# if the script ends with exit code 0, then no tests are run further, else all tests are run +import json import os import re import shlex import subprocess import sys +import urllib.request +def get_files_list(pr_number, repo="frappe/frappe"): + req = urllib.request.Request(f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files") + res = urllib.request.urlopen(req) + dump = json.loads(res.read().decode('utf8')) + return [change["filename"] for change in dump] + def get_output(command, shell=True): - print(command) - command = shlex.split(command) - return subprocess.check_output(command, shell=shell, encoding="utf8").strip() + print(command) + command = shlex.split(command) + return subprocess.check_output(command, shell=shell, encoding="utf8").strip() def is_py(file): - return file.endswith("py") + return file.endswith("py") -def is_js(file): - return file.endswith("js") +def is_ci(file): + return ".github" in file + +def is_frontend_code(file): + return file.lower().endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue")) def is_docs(file): - regex = re.compile(r'\.(md|png|jpg|jpeg)$|^.github|LICENSE') - return bool(regex.search(file)) + regex = re.compile(r'\.(md|png|jpg|jpeg|csv)$|^.github|LICENSE') + return bool(regex.search(file)) if __name__ == "__main__": - build_type = os.environ.get("TYPE") - before = os.environ.get("BEFORE") - after = os.environ.get("AFTER") - commit_range = before + '...' + after - print("Build Type: {}".format(build_type)) - print("Commit Range: {}".format(commit_range)) + files_list = sys.argv[1:] + build_type = os.environ.get("TYPE") + pr_number = os.environ.get("PR_NUMBER") + repo = os.environ.get("REPO_NAME") - try: - files_changed = get_output("git diff --name-only {}".format(commit_range), shell=False) - except Exception: - sys.exit(2) + # this is a push build, run all builds + if not pr_number: + os.system('echo "::set-output name=build::strawberry"') + sys.exit(0) - if "fatal" not in files_changed: - files_list = files_changed.split() - only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list) - only_js_changed = len(list(filter(is_js, files_list))) == len(files_list) - only_py_changed = len(list(filter(is_py, files_list))) == len(files_list) + files_list = files_list or get_files_list(pr_number=pr_number, repo=repo) - if only_docs_changed: - print("Only docs were updated, stopping build process.") - sys.exit(0) + if not files_list: + print("No files' changes detected. Build is shutting") + sys.exit(0) - if only_js_changed and build_type == "server": - print("Only JavaScript code was updated; Stopping Python build process.") - sys.exit(0) + ci_files_changed = any(f for f in files_list if is_ci(f)) + only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list) + only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list) + only_py_changed = len(list(filter(is_py, files_list))) == len(files_list) - if only_py_changed and build_type == "ui": - print("Only Python code was updated, stopping Cypress build process.") - sys.exit(0) + if ci_files_changed: + print("CI related files were updated, running all build processes.") - sys.exit(2) + elif only_docs_changed: + print("Only docs were updated, stopping build process.") + sys.exit(0) + + elif only_frontend_code_changed and build_type == "server": + print("Only Frontend code was updated; Stopping Python build process.") + sys.exit(0) + + elif only_py_changed and build_type == "ui": + print("Only Python code was updated, stopping Cypress build process.") + sys.exit(0) + + os.system('echo "::set-output name=build::strawberry"') diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index e8627a01fb..0dd4cd51d8 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -2,6 +2,11 @@ name: Patch on: [pull_request, workflow_dispatch] + +concurrency: + group: patch-mariadb-develop-${{ github.event.number }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-18.04 @@ -26,10 +31,21 @@ jobs: with: python-version: 3.7 + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" + env: + TYPE: "server" + PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} + - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -39,6 +55,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -51,10 +68,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -63,6 +82,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -70,12 +90,14 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: server - name: Run Patch Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | cd ~/frappe-bench/ wget https://frappeframework.com/files/v10-frappe.sql.gz diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 2476102e3d..fb6e56037c 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -6,6 +6,11 @@ on: push: branches: [ develop ] +concurrency: + group: server-mariadb-develop-${{ github.event.number }} + cancel-in-progress: true + + jobs: test: runs-on: ubuntu-18.04 @@ -35,17 +40,29 @@ jobs: with: python-version: 3.7 + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" + env: + TYPE: "server" + PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} + - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: 14 check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -55,6 +72,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -67,10 +85,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -79,6 +99,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -86,18 +107,22 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: server - name: Run Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage env: CI_BUILD_ID: ${{ github.run_id }} ORCHESTRATOR_URL: http://test-orchestrator.frappe.io - name: Upload Coverage Data + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + id: upload-coverage-data run: | cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} cd ${GITHUB_WORKSPACE} @@ -111,16 +136,29 @@ jobs: COVERALLS_SERVICE_NAME: ${{ github.event_name == 'pull_request' && 'github' || 'github-actions' }} COVERALLS_PARALLEL: true + - run: echo ${{ steps.check-build.outputs.build }} > guess-the-fruit.txt + - uses: actions/upload-artifact@v1 + with: + name: fruit + path: guess-the-fruit.txt + coveralls: name: Coverage Wrap Up needs: test container: python:3-slim runs-on: ubuntu-18.04 steps: + - uses: actions/download-artifact@v1 + with: + name: fruit + - run: echo "WILDCARD=$(cat fruit/guess-the-fruit.txt)" >> $GITHUB_ENV + - name: Clone + if: ${{ env.WILDCARD == 'strawberry' }} uses: actions/checkout@v2 - name: Coveralls Finished + if: ${{ env.WILDCARD == 'strawberry' }} run: | cd ${GITHUB_WORKSPACE} pip3 install coverage==5.5 diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 4325eebaad..1539e8c2d5 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -4,6 +4,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: server-postgres-develop-${{ github.event.number }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-18.04 @@ -37,17 +41,29 @@ jobs: with: python-version: 3.7 + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" + env: + TYPE: "server" + PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} + - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: '14' check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -57,6 +73,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -69,10 +86,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -81,6 +100,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -88,12 +108,14 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: postgres TYPE: server - name: Run Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator env: CI_BUILD_ID: ${{ github.run_id }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index f342c0709e..2a55546ec4 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -6,6 +6,10 @@ on: push: branches: [ develop ] +concurrency: + group: ui-develop-${{ github.event.number }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-18.04 @@ -35,17 +39,29 @@ jobs: with: python-version: 3.7 + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" + env: + TYPE: "ui" + PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} + - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: 14 check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -55,6 +71,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -67,10 +84,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -79,6 +98,7 @@ jobs: ${{ runner.os }}-yarn- - name: Cache cypress binary + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache @@ -88,6 +108,7 @@ jobs: ${{ runner.os }}- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -95,15 +116,18 @@ jobs: TYPE: ui - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: ui - name: Site Setup + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site execute frappe.utils.install.complete_setup_wizard - name: UI Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --headless --parallel --ci-build-id $GITHUB_RUN_ID env: CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb diff --git a/CODEOWNERS b/CODEOWNERS index 2dff157294..30cdb4d64d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -7,10 +7,13 @@ templates/ @surajshetty3416 www/ @surajshetty3416 integrations/ @leela -patches/ @surajshetty3416 +patches/ @surajshetty3416 @gavindsouza email/ @leela event_streaming/ @ruchamahabal data_import* @netchampfaris core/ @surajshetty3416 +database @gavindsouza +model @gavindsouza requirements.txt @gavindsouza commands/ @gavindsouza +workspace @shariquerik diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js new file mode 100644 index 0000000000..b77965ee1a --- /dev/null +++ b/cypress/integration/dashboard_links.js @@ -0,0 +1,63 @@ +context('Dashboard links', () => { + before(() => { + cy.visit('/login'); + cy.login(); + }); + + it('Adding a new contact, checking for the counter on the dashboard and deleting the created contact', () => { + cy.visit('/app/contact'); + cy.clear_filters(); + + cy.visit('/app/user'); + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); + + //To check if initially the dashboard contains only the "Contact" link and there is no counter + cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); + + //Adding a new contact + cy.get('.btn[data-doctype="Contact"]').click(); + cy.get('[data-doctype="Contact"][data-fieldname="first_name"]').type('Admin'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.visit('/app/user'); + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); + + //To check if the counter for contact doc is "1" after adding the contact + cy.get('[data-doctype="Contact"] > .count').should('contain', '1'); + cy.get('[data-doctype="Contact"]').contains('Contact').click(); + + //Deleting the newly created contact + cy.visit('/app/contact'); + 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.findByRole('button', {name: 'Yes'}).click({delay: 700}); + + + //To check if the counter from the "Contact" doc link is removed + cy.wait(700); + cy.visit('/app/user'); + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); + cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); + }); + + it('Report link in dashboard', () => { + cy.visit('/app/user'); + cy.visit('/app/user/Administrator'); + cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); + cy.findByText('Connections'); + cy.window() + .its('cur_frm') + .then(cur_frm => { + cur_frm.dashboard.data.reports = [ + { + 'label': 'Reports', + 'items': ['Permitted Documents For User'] + } + ]; + cur_frm.dashboard.render_report_links(); + cy.get('[data-report="Permitted Documents For User"]').contains('Permitted Documents For User').click(); + cy.findByText('Permitted Documents For User'); + cy.findByPlaceholderText('User').should("have.value", "Administrator"); + }); + }); +}); diff --git a/cypress/integration/datetime_field_form_validation.js b/cypress/integration/datetime_field_form_validation.js new file mode 100644 index 0000000000..66fdde6863 --- /dev/null +++ b/cypress/integration/datetime_field_form_validation.js @@ -0,0 +1,19 @@ +context('Datetime Field Validation', () => { + before(() => { + cy.login(); + cy.visit('/app/communication'); + cy.window().its('frappe').then(frappe => { + frappe.call("frappe.tests.ui_test_helpers.create_communication_records"); + }); + }); + + // validating datetime field value when value is set from backend and get validated on form load. + it('datetime field form validation', () => { + cy.visit('/app/communication'); + cy.get('a[title="Test Form Communication 1"]').invoke('attr', 'data-name') + .then((name) => { + cy.visit(`/app/communication/${name}`); + cy.get('.indicator-pill').should('contain', 'Open').should('have.class', 'red'); + }); + }); +}); \ No newline at end of file diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js new file mode 100644 index 0000000000..1b7c02d98c --- /dev/null +++ b/cypress/integration/folder_navigation.js @@ -0,0 +1,79 @@ +context('Folder Navigation', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/file'); + }); + + it('Adding Folders', () => { + //Adding filter to go into the home folder + cy.get('.filter-selector > .btn').findByText('1 filter').click(); + cy.findByRole('button', {name: 'Clear Filters'}).click(); + cy.get('.filter-action-buttons > .text-muted').findByText('+ Add a Filter').click(); + cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); + cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); + cy.get('.filter-action-buttons > div > .btn-primary').findByText('Apply Filters').click(); + + //Adding folder (Test Folder) + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group [data-label="New Folder"]').click(); + cy.get('form > [data-fieldname="value"]').type('Test Folder'); + cy.findByRole('button', {name: 'Create'}).click(); + }); + + it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { + //Navigating inside the Attachments folder + cy.get('[title="Attachments"] > span').click(); + + //To check if the URL formed after visiting the attachments folder is correct + cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); + cy.visit('/app/file/view/home/Attachments'); + + //Adding folder inside the attachments folder + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group [data-label="New Folder"]').click(); + cy.get('form > [data-fieldname="value"]').type('Test Folder'); + cy.findByRole('button', {name: 'Create'}).click(); + + //Navigating inside the added folder in the Attachments folder + cy.get('[title="Test Folder"] > span').click(); + + //To check if the URL is correct after visiting the Test Folder + cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); + cy.visit('/app/file/view/home/Attachments/Test%20Folder'); + + //Adding a file inside the Test Folder + cy.findByRole('button', {name: 'Add File'}).eq(0).click({force: true}); + cy.get('.file-uploader').findByText('Link').click(); + cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg'); + cy.findByRole('button', {name: 'Upload'}).click(); + + //To check if the added file is present in the Test Folder + cy.get('span.level-item > span').should('contain', 'Test Folder'); + cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); + cy.get('.list-row-checkbox').eq(0).click(); + + //Deleting the added file from the Test folder + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.wait(700); + cy.findByRole('button', {name: 'Yes'}).click(); + cy.wait(700); + + //Deleting the Test Folder + cy.visit('/app/file/view/home/Attachments'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); + }); + + it('Deleting Test Folder from the home', () => { + //Deleting the Test Folder added in the home directory + cy.visit('/app/file/view/home'); + cy.get('.level-left > .list-subject > .list-row-checkbox').eq(0).click({force: true, delay: 500}); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); + }); +}); diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index bc0cc17553..d40328d3cd 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -29,15 +29,8 @@ frappe.ui.form.on("File", "refresh", function(frm) { if (is_optimizable) { frm.add_custom_button(__("Optimize"), function() { frappe.show_alert(__("Optimizing image...")); - frappe.call({ - method: "frappe.core.doctype.file.file.optimize_saved_image", - args: { - doc_name: frm.doc.name, - }, - callback: function() { - frappe.show_alert(__("Image optimized")); - frappe.set_route("List", "File"); - } + frm.call("optimize_file").then(() => { + frappe.show_alert(__("Image optimized")); }); }); } diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b8ea134db5..36ff67ce7c 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -313,8 +313,16 @@ class File(Document): self.delete_file_data_content(only_thumbnail=True) def on_rollback(self): - self.flags.on_rollback = True - self.on_trash() + # if original_content flag is set, this rollback should revert the file to its original state + if self.flags.original_content: + file_path = self.get_full_path() + with open(file_path, "wb+") as f: + f.write(self.flags.original_content) + + # following condition is only executed when an insert has been rolledback + else: + self.flags.on_rollback = True + self.on_trash() def unzip(self): '''Unzip current file and replace it by its children''' @@ -531,6 +539,35 @@ class File(Document): if self.file_url: self.is_private = cint(self.file_url.startswith('/private')) + @frappe.whitelist() + def optimize_file(self): + if self.is_folder: + raise TypeError('Folders cannot be optimized') + + content_type = mimetypes.guess_type(self.file_name)[0] + is_local_image = content_type.startswith('image/') and self.file_size > 0 + is_svg = content_type == 'image/svg+xml' + + if not is_local_image: + raise NotImplementedError('Only local image files can be optimized') + + if is_svg: + raise TypeError('Optimization of SVG images is not supported') + + content = self.get_content() + file_path = self.get_full_path() + optimized_content = optimize_image(content, content_type) + + with open(file_path, 'wb+') as f: + f.write(optimized_content) + + self.file_size = len(optimized_content) + self.content_hash = get_content_hash(optimized_content) + # if rolledback, revert back to original + self.flags.original_content = content + frappe.local.rollback_observers.append(self) + self.save() + def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) @@ -838,22 +875,6 @@ def unzip_file(name): files = file_obj.unzip() return files -@frappe.whitelist() -def optimize_saved_image(doc_name): - file_doc = frappe.get_doc('File', doc_name) - content = file_doc.get_content() - content_type = mimetypes.guess_type(file_doc.file_name)[0] - - optimized_content = optimize_image(content, content_type) - - file_path = get_files_path(is_private=file_doc.is_private) - file_path = os.path.join(file_path.encode('utf-8'), file_doc.file_name.encode('utf-8')) - with open(file_path, 'wb+') as f: - f.write(optimized_content) - - file_doc.file_size = len(optimized_content) - file_doc.content_hash = get_content_hash(optimized_content) - file_doc.save() @frappe.whitelist() def get_attached_images(doctype, names): diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 5478d7ab85..18ad95eeba 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -440,6 +440,7 @@ class TestFile(unittest.TestCase): }).insert(ignore_permissions=True) self.assertRaisesRegex(frappe.exceptions.ValidationError, 'not a zip file', test_file.unzip) + class TestAttachment(unittest.TestCase): test_doctype = 'Test For Attachment' @@ -569,3 +570,68 @@ class TestFileUtils(unittest.TestCase): from frappe.core.doctype.file.file import create_new_folder folder = create_new_folder('test_folder', 'Home') self.assertTrue(folder.is_folder) + + +class TestFileOptimization(unittest.TestCase): + def test_optimize_file(self): + file_path = frappe.get_app_path("frappe", "tests/data/sample_image_for_optimization.jpg") + with open(file_path, "rb") as f: + file_content = f.read() + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": "sample_image_for_optimization.jpg", + "content": file_content + }).insert() + original_size = test_file.file_size + original_content_hash = test_file.content_hash + + test_file.optimize_file() + optimized_size = test_file.file_size + updated_content_hash = test_file.content_hash + + self.assertLess(optimized_size, original_size) + self.assertNotEqual(original_content_hash, updated_content_hash) + test_file.delete() + + def test_optimize_svg(self): + file_path = frappe.get_app_path("frappe", "tests/data/sample_svg.svg") + with open(file_path, "rb") as f: + file_content = f.read() + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": "sample_svg.svg", + "content": file_content + }).insert() + self.assertRaises(TypeError, test_file.optimize_file) + test_file.delete() + + def test_optimize_textfile(self): + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": "sample_text.txt", + "content": "Text files cannot be optimized" + }).insert() + self.assertRaises(NotImplementedError, test_file.optimize_file) + test_file.delete() + + def test_optimize_folder(self): + test_folder = frappe.get_doc("File", "Home/Attachments") + self.assertRaises(TypeError, test_folder.optimize_file) + + def test_revert_optimized_file_on_rollback(self): + file_path = frappe.get_app_path("frappe", "tests/data/sample_image_for_optimization.jpg") + with open(file_path, "rb") as f: + file_content = f.read() + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": "sample_image_for_optimization.jpg", + "content": file_content + }).insert() + image_path = test_file.get_full_path() + size_before_optimization = os.stat(image_path).st_size + + test_file.optimize_file() + frappe.db.rollback() + size_after_rollback = os.stat(image_path).st_size + self.assertEqual(size_before_optimization, size_after_rollback) + test_file.delete() \ No newline at end of file diff --git a/frappe/desk/doctype/workspace/test_workspace.py b/frappe/desk/doctype/workspace/test_workspace.py index 8aa3d57adf..f13a136c20 100644 --- a/frappe/desk/doctype/workspace/test_workspace.py +++ b/frappe/desk/doctype/workspace/test_workspace.py @@ -12,19 +12,20 @@ class TestWorkspace(unittest.TestCase): frappe.db.delete("DocType", {"module": "Test Module"}) frappe.delete_doc("Module Def", "Test Module") - def test_workspace_with_cards_specific_to_a_country(self): - workspace = create_workspace() - insert_card(workspace, "Card Label 1", "DocType 1", "DocType 2", "France") - insert_card(workspace, "Card Label 2", "DocType A", "DocType B") + # TODO: FIX ME - flaky test!!! + # def test_workspace_with_cards_specific_to_a_country(self): + # workspace = create_workspace() + # insert_card(workspace, "Card Label 1", "DocType 1", "DocType 2", "France") + # insert_card(workspace, "Card Label 2", "DocType A", "DocType B") - workspace.insert(ignore_if_duplicate = True) + # workspace.insert(ignore_if_duplicate = True) - cards = workspace.get_link_groups() + # cards = workspace.get_link_groups() - if frappe.get_system_settings('country') == "France": - self.assertEqual(len(cards), 2) - else: - self.assertEqual(len(cards), 1) + # if frappe.get_system_settings('country') == "France": + # self.assertEqual(len(cards), 2) + # else: + # self.assertEqual(len(cards), 1) def create_module(module_name): module = frappe.get_doc({ @@ -91,4 +92,4 @@ def create_doctype(doctype_name, module): 'permissions': [ {'role': 'System Manager'} ] - }).insert(ignore_if_duplicate = True) \ No newline at end of file + }).insert(ignore_if_duplicate = True) diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index b42c9c89a0..610eaf466a 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -391,7 +391,7 @@ def handle_duration_fieldtype_values(result, columns): return result -def build_xlsx_data(columns, data, visible_idx, include_indentation): +def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visible_idx=False): result = [[]] column_widths = [] @@ -407,7 +407,7 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation): # build table from result for row_idx, row in enumerate(data.result): # only pick up rows that are visible in the report - if row_idx in visible_idx: + if ignore_visible_idx or row_idx in visible_idx: row_data = [] if isinstance(row, dict): for col_idx, column in enumerate(data.columns): diff --git a/frappe/desk/search.py b/frappe/desk/search.py index f9b65fc98e..9a5e7533d1 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -265,6 +265,7 @@ def get_users_for_mentions(): 'name': ['not in', ('Administrator', 'Guest')], 'allowed_in_mentions': True, 'user_type': 'System User', + 'enabled': True, }) def get_user_groups(): diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index f30279e308..ccfff594b7 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -13,6 +13,7 @@ from frappe.utils import (format_time, get_link_to_form, get_url_to_report, from frappe.model.naming import append_number_if_name_exists from frappe.utils.csvutils import to_csv from frappe.utils.xlsxutils import make_xlsx +from frappe.desk.query_report import build_xlsx_data max_reports_per_user = frappe.local.conf.max_reports_per_user or 3 @@ -99,13 +100,21 @@ class AutoEmailReport(Document): return self.get_html_table(columns, data) elif self.format == 'XLSX': - spreadsheet_data = self.get_spreadsheet_data(columns, data) - xlsx_file = make_xlsx(spreadsheet_data, "Auto Email Report") + report_data = frappe._dict() + report_data['columns'] = columns + report_data['result'] = data + + xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths) return xlsx_file.getvalue() elif self.format == 'CSV': - spreadsheet_data = self.get_spreadsheet_data(columns, data) - return to_csv(spreadsheet_data) + report_data = frappe._dict() + report_data['columns'] = columns + report_data['result'] = data + + xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + return to_csv(xlsx_data) else: frappe.throw(_('Invalid Output Format')) @@ -126,18 +135,6 @@ class AutoEmailReport(Document): 'edit_report_settings': get_link_to_form('Auto Email Report', self.name) }) - @staticmethod - def get_spreadsheet_data(columns, data): - out = [[_(df.label) for df in columns], ] - for row in data: - new_row = [] - out.append(new_row) - for df in columns: - if df.fieldname not in row: continue - new_row.append(frappe.format(row[df.fieldname], df, row)) - - return out - def get_file_name(self): return "{0}.{1}".format(self.report.replace(" ", "-").replace("/", "-"), self.format.lower()) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 752543f46a..5603b2daae 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -874,7 +874,7 @@ class BaseDocument(object): return self._precision[cache_key][fieldname] - def get_formatted(self, fieldname, doc=None, currency=None, absolute_value=False, translated=False): + def get_formatted(self, fieldname, doc=None, currency=None, absolute_value=False, translated=False, format=None): from frappe.utils.formatters import format_value df = self.meta.get_field(fieldname) @@ -898,7 +898,7 @@ class BaseDocument(object): if (absolute_value or doc.get('absolute_value')) and isinstance(val, (int, float)): val = abs(self.get(fieldname)) - return format_value(val, df=df, doc=doc, currency=currency) + return format_value(val, df=df, doc=doc, currency=currency, format=format) def is_print_hide(self, fieldname, df=None, for_print=True): """Returns true if fieldname is to be hidden for print. diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index f216374526..b2f1428967 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -567,7 +567,7 @@ - diff --git a/frappe/public/js/frappe/data_import/data_exporter.js b/frappe/public/js/frappe/data_import/data_exporter.js index 03e6288856..8fa5a08945 100644 --- a/frappe/public/js/frappe/data_import/data_exporter.js +++ b/frappe/public/js/frappe/data_import/data_exporter.js @@ -13,6 +13,13 @@ frappe.data_import.DataExporter = class DataExporter { this.dialog = new frappe.ui.Dialog({ title: __('Export Data'), fields: [ + { + fieldtype: 'Select', + fieldname: 'file_type', + label: __('File Type'), + options: ['Excel', 'CSV'], + default: 'CSV' + }, { fieldtype: 'Select', fieldname: 'export_records', @@ -45,13 +52,6 @@ frappe.data_import.DataExporter = class DataExporter { fieldname: 'filter_area', depends_on: doc => doc.export_records === 'by_filter' }, - { - fieldtype: 'Select', - fieldname: 'file_type', - label: __('File Type'), - options: ['Excel', 'CSV'], - default: 'CSV' - }, { fieldtype: 'Section Break' }, @@ -141,7 +141,7 @@ frappe.data_import.DataExporter = class DataExporter { let for_insert = this.exporting_for === 'Insert New Records'; let section_title = for_insert ? __('Select Fields To Insert') : __('Select Fields To Update'); let $select_all_buttons = $(` -
+
${section_title}
`).click(() => { - this.expanded = !this.expanded; - this.refresh_height(); - this.toggle_label(); - }).appendTo(this.$input_wrapper); + // styling this.ace_editor_target.addClass('border rounded'); this.ace_editor_target.css('height', 300); @@ -22,6 +17,21 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex // initialize const ace = window.ace; this.editor = ace.edit(this.ace_editor_target.get(0)); + + if (this.df.max_lines || this.df.min_lines) { + if (this.df.max_lines) + this.editor.setOption("maxLines", this.df.max_lines); + if (this.df.min_lines) + this.editor.setOption("minLines", this.df.min_lines); + } else { + this.expanded = false; + this.$expand_button = $(``).click(() => { + this.expanded = !this.expanded; + this.refresh_height(); + this.toggle_label(); + }).appendTo(this.$input_wrapper); + } + this.editor.setTheme('ace/theme/tomorrow'); this.editor.setOption("showPrintMargin", false); this.editor.setOption("wrap", this.df.wrap); diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index 341a933066..f7a2798a99 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -36,4 +36,9 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co $tp.$secondsText.prev().css('display', 'none'); } } + + get_model_value() { + let value = super.get_model_value(); + return frappe.datetime.get_datetime_as_string(value); + } }; diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index 420704149b..da06f7228d 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -283,21 +283,13 @@ frappe.ui.form.Dashboard = class FormDashboard { $(frappe.render_template('form_links', this.data)) .appendTo(transactions_area_body); - if (this.data.reports && this.data.reports.length) { - $(frappe.render_template('report_links', this.data)) - .appendTo(transactions_area_body); - } + this.render_report_links(); // bind links transactions_area_body.find(".badge-link").on('click', function() { me.open_document_list($(this).closest('.document-link')); }); - // bind reports - transactions_area_body.find(".report-link").on('click', function() { - me.open_report($(this).parent()); - }); - // bind open notifications transactions_area_body.find('.open-notification').on('click', function() { me.open_document_list($(this).parent(), true); @@ -311,6 +303,18 @@ frappe.ui.form.Dashboard = class FormDashboard { this.data_rendered = true; } + render_report_links() { + let parent = this.transactions_area; + if (this.data.reports && this.data.reports.length) { + $(frappe.render_template('report_links', this.data)) + .appendTo(parent); + // bind reports + parent.find(".report-link").on('click', (e) => { + this.open_report($(e.target).parent()); + }); + } + } + open_report($link) { let report = $link.attr('data-report'); @@ -318,6 +322,7 @@ frappe.ui.form.Dashboard = class FormDashboard { ? (this.data.non_standard_fieldnames[report] || this.data.fieldname) : this.data.fieldname; + frappe.provide('frappe.route_options'); frappe.route_options[fieldname] = this.frm.doc.name; frappe.set_route("query-report", report); } diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js index 369c8e7a5a..3b99560411 100644 --- a/frappe/public/js/frappe/list/bulk_operations.js +++ b/frappe/public/js/frappe/list/bulk_operations.js @@ -261,4 +261,14 @@ export default class BulkOperations { }); dialog.show(); } + + export(doctype, docnames) { + frappe.require('data_import_tools.bundle.js', () => { + const data_exporter = new frappe.data_import.DataExporter(doctype, 'Insert New Records'); + data_exporter.dialog.set_value('export_records', 'by_filter'); + data_exporter.filter_group.add_filters_to_filter_group( + [[doctype, "name", "in", docnames, false]] + ); + }); + } } diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 8a0e43c8f3..81b2c4ff8d 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -367,6 +367,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if ( !this.settings.hide_name_column && + this.meta.title_field && this.meta.title_field !== 'name' ) { this.columns.push({ @@ -867,8 +868,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { filters: this.get_filters_for_args() }).then(total_count => { this.total_count = total_count || current_count; + this.count_without_children = count_without_children !== current_count ? count_without_children : undefined; let str = __('{0} of {1}', [current_count, this.total_count]); - if (count_without_children !== current_count) { + if (this.count_without_children) { str = __('{0} of {1} ({2} rows with children)', [count_without_children, this.total_count, current_count]); } return str; @@ -1730,11 +1732,25 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { }; }; + const bulk_export = () => { + return { + label: __("Export"), + action: () => { + const docnames = this.get_checked_items(true); + + bulk_operations.export(doctype, docnames); + }, + standard: true + }; + }; + // bulk edit if (has_editable_fields(doctype)) { actions_menu_items.push(bulk_edit()); } + actions_menu_items.push(bulk_export()); + // bulk assignment actions_menu_items.push(bulk_assignment()); diff --git a/frappe/public/js/frappe/recorder/RecorderDetail.vue b/frappe/public/js/frappe/recorder/RecorderDetail.vue index d17a8f0ec4..0b6354db7e 100644 --- a/frappe/public/js/frappe/recorder/RecorderDetail.vue +++ b/frappe/public/js/frappe/recorder/RecorderDetail.vue @@ -1,5 +1,5 @@