diff --git a/.babelrc.json b/.babelrc.json new file mode 100644 index 000000000000..9164ce7ce48e --- /dev/null +++ b/.babelrc.json @@ -0,0 +1,18 @@ +{ + "sourceType": "unambiguous", + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "chrome": 100, + "safari": 15, + "firefox": 91 + } + } + ], + "@babel/preset-typescript", + "@babel/preset-react" + ], + "plugins": [] +} diff --git a/.circleci/config.yml b/.circleci/config.yml index c570d582b70f..9cc227255fe3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,33 +3,37 @@ version: 2 jobs: backend: docker: - - image: cimg/python:3.8.11 + - image: cimg/python:3.12 environment: PIPENV_VENV_IN_PROJECT: true steps: - checkout - restore_cache: - key: pipenv-v1-{{ checksum "setup.py" }} + key: pipenv-v2-{{ checksum "setup.py" }} # Only install if .venv wasn’t cached. - run: | if [[ ! -e ".venv" ]]; then pipenv install -e .[testing,docs] fi - save_cache: - key: pipenv-v1-{{ checksum "setup.py" }} + key: pipenv-v2-{{ checksum "setup.py" }} paths: - .venv - run: pipenv run ruff check . - - run: pipenv run black --target-version py37 --check --diff . + - run: pipenv run ruff format --check . - run: pipenv run semgrep --config .semgrep.yml --error . - run: git ls-files '*.html' | xargs pipenv run djhtml --check - run: pipenv run curlylint --parse-only wagtail - run: pipenv run doc8 docs - - run: DATABASE_NAME=wagtail.db pipenv run python -u runtests.py + - run: + name: Run tests + command: | + export PYTHONUNBUFFERED=1 + WAGTAIL_CHECK_TEMPLATE_NUMBER_FORMAT=1 pipenv run python -u runtests.py --parallel=2 frontend: docker: - - image: cimg/node:18.12 + - image: cimg/node:20.9 steps: - checkout - restore_cache: @@ -58,36 +62,40 @@ jobs: ui_tests: docker: - - image: cimg/python:3.8.11-browsers + - image: cimg/python:3.12-browsers environment: PIPENV_VENV_IN_PROJECT: true DJANGO_SETTINGS_MODULE: wagtail.test.settings_ui + DJANGO_DEBUG: true steps: - checkout - attach_workspace: at: ~/project - restore_cache: - key: pipenv-v1-{{ checksum "setup.py" }} + key: pipenv-v2-{{ checksum "setup.py" }} # Only install if .venv wasn’t cached. - run: | if [[ ! -e ".venv" ]]; then pipenv install -e .[testing] fi - save_cache: - key: pipenv-v1-{{ checksum "setup.py" }} + key: pipenv-v2-{{ checksum "setup.py" }} paths: - .venv - restore_cache: - key: ui_tests-npm_integration-v1-{{ checksum "client/tests/integration/package-lock.json" }} + key: ui_tests-npm_integration-v2-{{ checksum "client/tests/integration/package-lock.json" }} # Only install if node_modules wasn’t cached. - run: | if [[ ! -e "client/tests/integration/node_modules" ]]; then npm --prefix ./client/tests/integration ci fi - save_cache: - key: ui_tests-npm_integration-v1-{{ checksum "client/tests/integration/package-lock.json" }} + key: ui_tests-npm_integration-v2-{{ checksum "client/tests/integration/package-lock.json" }} paths: - client/tests/integration/node_modules + # Also cache the global location where Puppeteer stores browsers. + # https://pptr.dev/guides/configuration/#changing-the-default-cache-directory + - ~/.cache/puppeteer - run: pipenv run ./wagtail/test/manage.py migrate - run: command: pipenv run ./wagtail/test/manage.py runserver 0:8000 @@ -106,13 +114,14 @@ jobs: - run: command: pipenv run ./wagtail/test/manage.py collectstatic --noinput environment: + DJANGO_DEBUG: false STATICFILES_STORAGE: manifest - store_test_results: path: ./reports/jest nightly-build: docker: - - image: cimg/python:3.8.11-node + - image: cimg/python:3.12-node steps: - checkout - run: pip install --user wheel boto3 diff --git a/.coveragerc b/.coveragerc index 1b9e8d18eabb..38a34a15a4aa 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,7 @@ # .coveragerc to control coverage.py [run] branch = True +concurrency = multiprocessing,thread source = wagtail diff --git a/.editorconfig b/.editorconfig index 8d29457a7348..bb47f87f211f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,3 +25,7 @@ indent_size = 2 [*.md] trim_trailing_whitespace = false + +# Make sure this file _doesn’t_ end with a final newline. It breaks rendering. +[wagtail/admin/templates/wagtailadmin/shared/icon.html] +insert_final_newline = false diff --git a/.eslintrc.js b/.eslintrc.js index c169583886b7..c80413f60e33 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,6 +2,7 @@ module.exports = { extends: [ '@wagtail/eslint-config-wagtail', 'plugin:@typescript-eslint/recommended', + 'plugin:storybook/recommended', ], parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], @@ -44,7 +45,7 @@ module.exports = { 'react/jsx-filename-extension': ['error', { extensions: ['.js', '.tsx'] }], 'no-underscore-dangle': [ 'error', - { allow: ['__REDUX_DEVTOOLS_EXTENSION__'] }, + { allow: ['__REDUX_DEVTOOLS_EXTENSION__', '_tippy'] }, ], // this rule can be confusing as it forces some non-intuitive code for variable assignment 'prefer-destructuring': 'off', @@ -54,6 +55,14 @@ module.exports = { 'import/resolver': { node: { extensions: ['.js', '.ts', '.tsx'] } }, }, overrides: [ + // Rules that needs to be adjusted for TypeScript only files + { + files: ['*.ts'], + rules: { + '@typescript-eslint/no-shadow': 'error', + 'no-shadow': 'off', + }, + }, // Rules that we are ignoring currently due to legacy code in React components only { files: ['client/src/components/**'], @@ -61,7 +70,6 @@ module.exports = { 'jsx-a11y/click-events-have-key-events': 'off', 'jsx-a11y/interactive-supports-focus': 'off', 'jsx-a11y/no-noninteractive-element-interactions': 'off', - 'jsx-a11y/role-supports-aria-props': 'off', 'no-restricted-syntax': 'off', 'react-hooks/exhaustive-deps': 'off', 'react-hooks/rules-of-hooks': 'off', @@ -99,7 +107,7 @@ module.exports = { }, }, { - selector: 'property', + selector: 'classProperty', format: ['camelCase'], custom: { // Use Stimulus values where possible for internal state, avoid a generic state object as these are not reactive. @@ -135,11 +143,14 @@ module.exports = { ], rules: { '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-this-alias': 'off', '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-var-requires': 'off', 'global-require': 'off', 'import/first': 'off', 'import/no-extraneous-dependencies': 'off', + 'jsx-a11y/control-has-associated-label': 'off', + 'no-new': 'off', 'no-unused-expressions': 'off', 'react/function-component-definition': 'off', 'react/jsx-props-no-spreading': 'off', @@ -148,17 +159,14 @@ module.exports = { // Files that use jquery via a global { files: [ - 'docs/_static/**', - 'wagtail/contrib/modeladmin/static_src/wagtailmodeladmin/js/prepopulate.js', + 'wagtail/contrib/search_promotions/static_src/wagtailsearchpromotions/js/query-chooser-modal.js', 'wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.js', + 'wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/queries/chooser_field.js', 'wagtail/documents/static_src/wagtaildocs/js/add-multiple.js', 'wagtail/embeds/static_src/wagtailembeds/js/embed-chooser-modal.js', 'wagtail/images/static_src/wagtailimages/js/add-multiple.js', 'wagtail/images/static_src/wagtailimages/js/focal-point-chooser.js', 'wagtail/images/static_src/wagtailimages/js/image-url-generator.js', - 'wagtail/search/static_src/wagtailsearch/js/query-chooser-modal.js', - 'wagtail/search/templates/wagtailsearch/queries/chooser_field.js', - 'wagtail/snippets/static_src/wagtailsnippets/js/snippet-multiple-select.js', 'wagtail/users/static_src/wagtailusers/js/group-form.js', ], globals: { $: 'readonly', jQuery: 'readonly' }, @@ -168,9 +176,7 @@ module.exports = { files: ['wagtail/**/**'], globals: { buildExpandingFormset: 'readonly', - cancelSpinner: 'readonly', escapeHtml: 'readonly', - jsonData: 'readonly', ModalWorkflow: 'readonly', DOCUMENT_CHOOSER_MODAL_ONLOAD_HANDLERS: 'writable', EMBED_CHOOSER_MODAL_ONLOAD_HANDLERS: 'writable', @@ -195,16 +201,10 @@ module.exports = { 'consistent-return': 'off', 'func-names': 'off', 'id-length': 'off', - 'indent': 'off', - 'key-spacing': 'off', - 'new-cap': 'off', - 'newline-per-chained-call': 'off', 'no-param-reassign': 'off', 'no-underscore-dangle': 'off', 'object-shorthand': 'off', 'prefer-arrow-callback': 'off', - 'quote-props': 'off', - 'space-before-function-paren': 'off', 'vars-on-top': 'off', }, }, diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 55d20e1bcd1c..41deac3e0b32 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -10,3 +10,5 @@ af942a27e41b47e257b6cd46c01a13cd381fed04 01986cfa1702929352ac8b6d58ada6070da2c700 # Initial black reformatting (#7967) d10f15e55806c6944827d801cd9c2d53f5da4186 +# Initial ruff reformatting (#11220) +f8fc2c3a2052988f7b458e26f621e058d67a76c9 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1da1d57f0bb0..a4256ffbe2f7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,27 +1,42 @@ -# Contributing to Wagtail +# Contributing Thank you for considering to help Wagtail. We welcome all support, whether on bug reports, code, design, reviews, tests, documentation, translations or just feature requests. -## Using the issue tracker +## Working on an issue -The [issue tracker](https://github.com/wagtail/wagtail/issues) is -the preferred channel for [bug reports](#bugs), [features requests](#features) -and [submitting pull requests](#pull-requests). Please don't use the issue tracker -for support - use [the 'wagtail' tag on Stack Overflow](https://stackoverflow.com/questions/tagged/wagtail) (preferred) or our [Wagtail support group](https://groups.google.com/forum/#!forum/wagtail). +👉 If an issue isn’t being worked on by anyone, go for it! **No need to ask "please assign me this issue".** Add a comment to claim the issue once you’re ready to start. -## New code +Please review the [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html). +You might like to start by checking issues with the [good first issue](https://github.com/wagtail/wagtail/labels/good%20first%20issue) label. -Please review the -[contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html). -You might like to start by checking issues with the -[good first issue](https://github.com/wagtail/wagtail/labels/good%20first%20issue) label. +## Reporting bugs + +To report bugs, use our [issue tracker](https://github.com/wagtail/wagtail/issues). + +## Feature requests + +Use our [issue tracker](https://github.com/wagtail/wagtail/issues) for feature requests, or go the [Wagtail Slack](https://github.com/wagtail/wagtail/wiki/Slack) or [Discussions](https://github.com/wagtail/wagtail/discussions) if you want to discuss an idea before requesting it. + +## Support + +Please don't use the issue tracker for support - use [the 'wagtail' tag on Stack Overflow](https://stackoverflow.com/questions/tagged/wagtail) (preferred) or the [#support channel](https://github.com/wagtail/wagtail/wiki/Slack#support) on the [Wagtail Slack](https://github.com/wagtail/wagtail/wiki/Slack). ## Code reviews -We welcome code reviews from everyone. There's always a list of pull requests tagged ['Needs review'](https://github.com/wagtail/wagtail/pulls?q=is%3Apr+is%3Aopen+label%3A%22Needs+review%22). +We welcome code reviews from everyone. There's always a list of pull requests tagged [status:Needs Review](https://github.com/wagtail/wagtail/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22status%3ANeeds+Review%22). + +## Triaging issues + +We welcome help with triaging issues and pull requests. You can help by: + +- Adding more details or your own perspective to bug reports or feature requests. +- Attempting to reproduce issues tagged [status:Unconfirmed](https://github.com/wagtail/wagtail/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Astatus%3AUnconfirmed) and sharing your findings. +- Reviewing or otherwise offering your feedback on pull requests. + +View our [issue tracking guidelines](https://docs.wagtail.org/en/latest/contributing/issue_tracking.html) for more information. ## Translations diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index fe85eb4c10bf..64ffeb4e77cd 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ custom: ['https://wagtail.org/sponsor/'] +open_collective: wagtail diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md index fdda5d0e4a5a..ae8dc89c7537 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.md +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -32,3 +32,13 @@ Any other relevant information. For example, why do you consider this a bug and - Django version: Look in your requirements.txt, or run `pip show django | grep Version`. - Wagtail version: Look at the bottom of the Settings menu in the Wagtail admin, or run `pip show wagtail | grep Version:`. - Browser version: You can use https://www.whatsmybrowser.org/ to find this out. + +### Working on this + + + +Anyone can contribute to this. View our [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html), add a comment to the issue once you’re ready to start. diff --git a/.github/ISSUE_TEMPLATE/DOCUMENTATION.md b/.github/ISSUE_TEMPLATE/DOCUMENTATION.md index 63da698e4708..3920163f4bd5 100644 --- a/.github/ISSUE_TEMPLATE/DOCUMENTATION.md +++ b/.github/ISSUE_TEMPLATE/DOCUMENTATION.md @@ -29,3 +29,13 @@ assignees: '' + +### Working on this + + + +Anyone can contribute to this. View our [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html), add a comment to the issue once you’re ready to start. diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md index 8cadabab9d93..8270d0a8e76f 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md @@ -39,3 +39,13 @@ assignees: '' --> (Write your answer here.) + +### Working on this + + + +Anyone can contribute to this. View our [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html), add a comment to the issue once you’re ready to start. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 79eebc6b87b4..1d62a0023006 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,26 +1 @@ - - -Fixes #... - - - - - - - -_Please check the following:_ - -- [ ] Do the tests still pass?[^1] -- [ ] Does the code comply with the style guide? - - [ ] Run `make lint` from the Wagtail root. -- [ ] For Python changes: Have you added tests to cover the new/fixed behaviour? -- [ ] For front-end changes: Did you test on all of Wagtail’s supported environments?[^2] - - [ ] **Please list the exact browser and operating system versions you tested**: - - [ ] **Please list which assistive technologies [^3] you tested**: -- [ ] For new features: Has the documentation been updated accordingly? - -**Please describe additional details for testing this change**. - -[^1]: [Development Testing](https://docs.wagtail.org/en/latest/contributing/developing.html#testing) -[^2]: [Browser and device support](https://docs.wagtail.org/en/latest/contributing/developing.html#browser-and-device-support) -[^3]: [Accessibility Target](https://docs.wagtail.org/en/latest/contributing/developing.html#accessibility-targets) +_Please describe the problem you're fixing here. Include the issue number, if applicable._ diff --git a/.github/wagtail-inverse.svg b/.github/wagtail-inverse.svg index 34875ae40779..e214e7ffd1b6 100644 --- a/.github/wagtail-inverse.svg +++ b/.github/wagtail-inverse.svg @@ -1 +1,9 @@ - + + + + + + + + + diff --git a/.github/wagtail.svg b/.github/wagtail.svg index e72e24987e79..5c666bb16712 100644 --- a/.github/wagtail.svg +++ b/.github/wagtail.svg @@ -1 +1,10 @@ - + + + + + + + + + + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10ac0faecfab..5708dccf9467 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,25 +13,32 @@ concurrency: cancel-in-progress: true # Our test suite should cover: -# - all supported databases against current Python and Django +# - all supported databases against current Python and Django (including psycopg v2 and v3) # - at least one test run for each older supported version of Python and Django # - at least one test run for each supported Elasticsearch version # - a test run against Django's git main and active stable branch (allowing failures) -# - test runs with USE_EMAIL_USER_MODEL=yes and DISABLE_TIMEZONE=yes +# - test runs with USE_EMAIL_USER_MODEL=yes, DISABLE_TIMEZONE=yes and WAGTAIL_CHECK_TEMPLATE_NUMBER_FORMAT=1 # Current configuration: -# - django 3.2, python 3.7, postgres -# - django 3.2, python 3.8, mysql -# - django 4.1, python 3.9, sqlite -# - django 4.2, python 3.10, mysql -# - django 4.1, python 3.10, postgres, USE_EMAIL_USER_MODEL=yes -# - django 4.2, python 3.11, postgres, DISABLE_TIMEZONE=yes -# - django stable/4.2.x, python 3.10, postgres (allow failures) -# - django main, python 3.10, postgres (allow failures) -# - elasticsearch 5, django 3.2, python 3.7, sqlite -# - elasticsearch 6, django 3.2, python 3.7, postgres -# - elasticsearch 7, django 4.1, python 3.8, postgres -# - elasticsearch 7, django 4.2, python 3.9, sqlite, USE_EMAIL_USER_MODEL=yes +# - django 4.2, python 3.9, postgres:12, psycopg 2, parallel +# - django 4.2, python 3.10, mysql:8.0 +# - django 4.2, python 3.11, mariadb:10.5 +# - django 5.0, python 3.11, sqlite, WAGTAIL_CHECK_TEMPLATE_NUMBER_FORMAT=1 +# - django 5.1, python 3.12, mysql:8.4, parallel, USE_EMAIL_USER_MODEL=yes +# - django 5.1, python 3.12, mariadb:11.4, USE_EMAIL_USER_MODEL=yes +# - django 5.1, python 3.12, sqlite, parallel, USE_EMAIL_USER_MODEL=yes +# - django 5.1, python 3.12, postgres:15, psycopg 3, parallel, DISABLE_TIMEZONE=yes +# - django stable/5.1.x, python 3.11, postgres:15, psycopg 3 (allow failures) +# - django main, python 3.12, postgres:latest, psycopg 3, parallel (allow failures) +# - elasticsearch 7, django 4.2, python 3.9, postgres:latest, psycopg 2 +# - opensearch 2, django 5.0, python 3.10, sqlite +# - elasticsearch 8, django 5.1, python 3.12, sqlite, USE_EMAIL_USER_MODEL=yes + +# Some tests are run in parallel by passing --parallel to runtests.py. +# When running tests in parallel, some errors cannot be pickled and result in +# non-helpful tracebacks (see https://code.djangoproject.com/ticket/29023). +# Thus, we keep one test run without --parallel for each supported database. +# ElasticSearch tests are not run in parallel as the test suite is not thread-safe. permissions: contents: read # to fetch code (actions/checkout) @@ -42,30 +49,40 @@ jobs: strategy: matrix: include: - - python: '3.9' - django: 'Django>=4.1,<4.2' + - python: '3.11' + django: 'Django>=5.0,<5.1' + check_template_number_format: '1' + - python: '3.12' + django: 'Django>=5.1,<5.2' + emailuser: emailuser + parallel: '--parallel' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e .[testing] + pip install -e '.[testing]' --config-settings editable_mode=strict pip install "${{ matrix.django }}" - name: Test run: | - coverage run --parallel-mode --source wagtail runtests.py + WAGTAIL_FAIL_ON_VERSIONED_STATIC=1 DJANGO_SETTINGS_MODULE=wagtail.test.settings django-admin check + coverage run --parallel-mode --source wagtail runtests.py ${{ matrix.parallel }} env: DATABASE_ENGINE: django.db.backends.sqlite3 + USE_EMAIL_USER_MODEL: ${{ matrix.emailuser }} + WAGTAIL_CHECK_TEMPLATE_NUMBER_FORMAT: ${{ matrix.check_template_number_format }} - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage-data + name: coverage-data-${{ github.job }}-${{ strategy.job-index }} path: .coverage.* + include-hidden-files: true test-postgres: runs-on: ubuntu-latest @@ -73,35 +90,32 @@ jobs: strategy: matrix: include: - - python: '3.7' - django: 'Django>=3.2,<3.3' - experimental: false - - python: '3.11' + - python: '3.9' django: 'Django>=4.2,<4.3' - postgres: 'postgres:12' - notz: notz + psycopg: 'psycopg2>=2.6' experimental: false - - python: '3.10' - django: 'Django>=4.1,<4.2' + parallel: '--parallel' + - python: '3.12' + django: 'Django>=5.0,<5.1' + psycopg: 'psycopg>=3.1.8' + postgres: 'postgres:15' + notz: notz experimental: false - emailuser: emailuser - - python: '3.10' - django: 'git+https://github.com/django/django.git@stable/4.2.x#egg=Django' + parallel: '--parallel' + - python: '3.11' + django: 'git+https://github.com/django/django.git@stable/5.1.x#egg=Django' + psycopg: 'psycopg>=3.1.8' + postgres: 'postgres:15' experimental: true - postgres: 'postgres:12' - - python: '3.10' + - python: '3.12' django: 'git+https://github.com/django/django.git@main#egg=Django' + psycopg: 'psycopg>=3.1.8' experimental: true - postgres: 'postgres:12' - install_extras: | - pip uninstall -y django-modelcluster django-taggit - pip install \ - git+https://github.com/wagtail/django-modelcluster.git@main#egg=django-modelcluster \ - git+https://github.com/laymonage/django-taggit.git@django-5.0#egg=django-taggit - + postgres: 'postgres:latest' + parallel: '--parallel' services: postgres: - image: ${{ matrix.postgres || 'postgres:11' }} + image: ${{ matrix.postgres || 'postgres:12' }} env: POSTGRES_PASSWORD: postgres ports: @@ -109,21 +123,23 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install "psycopg2>=2.6" - pip install -e .[testing] + pip install "${{ matrix.psycopg }}" + pip install -e '.[testing]' --config-settings editable_mode=strict pip install "${{ matrix.django }}" ${{ matrix.install_extras }} - name: Test run: | - coverage run --parallel-mode --source wagtail runtests.py + WAGTAIL_FAIL_ON_VERSIONED_STATIC=1 DJANGO_SETTINGS_MODULE=wagtail.test.settings django-admin check + coverage run --parallel-mode --source wagtail runtests.py ${{ matrix.parallel }} env: DATABASE_ENGINE: django.db.backends.postgresql DATABASE_HOST: localhost @@ -132,10 +148,11 @@ jobs: USE_EMAIL_USER_MODEL: ${{ matrix.emailuser }} DISABLE_TIMEZONE: ${{ matrix.notz }} - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage-data + name: coverage-data-${{ github.job }}-${{ strategy.job-index }} path: .coverage.* + include-hidden-files: true test-mysql: runs-on: ubuntu-latest @@ -143,102 +160,75 @@ jobs: strategy: matrix: include: - - python: '3.8' - django: 'Django>=3.2,<3.3' - experimental: false - python: '3.10' django: 'Django>=4.2,<4.3' experimental: false - + - python: '3.11' + django: 'Django>=4.2,<4.3' + experimental: false + mysql: 'mariadb:10.5' + - python: '3.12' + django: 'Django>=5.1,<5.2' + experimental: false + mysql: 'mariadb:11.4' + emailuser: emailuser + - python: '3.12' + django: 'Django>=5.1,<5.2' + experimental: false + parallel: '--parallel' + mysql: 'mysql:8.4' + emailuser: emailuser services: mysql: - image: mysql:8.0.23 + image: ${{ matrix.mysql || 'mysql:8.0' }} env: + MARIADB_ROOT_PASSWORD: root + MYSQL_ROOT_PASSWORD: root MYSQL_ALLOW_EMPTY_PASSWORD: yes MYSQL_DATABASE: wagtail + HEALTH_CMD: ${{ startsWith(matrix.mysql, 'mariadb') && 'healthcheck.sh --connect --innodb_initialized' || 'mysqladmin --protocol=tcp ping' }} ports: - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 --cap-add=sys_nice + options: --health-cmd=$HEALTH_CMD --health-interval=10s --health-timeout=5s --health-retries=3 --cap-add=sys_nice steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip pip install "mysqlclient>=1.4,<2" - pip install -e .[testing] + pip install -e '.[testing]' --config-settings editable_mode=strict pip install "${{ matrix.django }}" - name: Test run: | - coverage run --parallel-mode --source wagtail runtests.py + WAGTAIL_FAIL_ON_VERSIONED_STATIC=1 DJANGO_SETTINGS_MODULE=wagtail.test.settings django-admin check + coverage run --parallel-mode --source wagtail runtests.py ${{ matrix.parallel }} env: DATABASE_ENGINE: django.db.backends.mysql DATABASE_HOST: '127.0.0.1' DATABASE_USER: root + DATABASE_PASSWORD: root + USE_EMAIL_USER_MODEL: ${{ matrix.emailuser }} + DISABLE_TIMEZONE: ${{ matrix.notz }} - name: Upload coverage data - uses: actions/upload-artifact@v3 - with: - name: coverage-data - path: .coverage.* - - # https://github.com/elastic/elastic-github-actions doesn't work for Elasticsearch 5, - # but https://github.com/getong/elasticsearch-action does - test-sqlite-elasticsearch5: - runs-on: ubuntu-latest - strategy: - matrix: - include: - - python: '3.7' - django: 'Django>=3.2,<3.3' - steps: - - name: Configure sysctl limits - run: | - sudo swapoff -a - sudo sysctl -w vm.swappiness=1 - sudo sysctl -w fs.file-max=262144 - sudo sysctl -w vm.max_map_count=262144 - - uses: getong/elasticsearch-action@v1.2 - with: - elasticsearch version: 5.6.9 - host port: 9200 - container port: 9200 - host node port: 9300 - node port: 9300 - discovery type: 'single-node' - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[testing] - pip install "${{ matrix.django }}" - pip install "elasticsearch>=5,<6" - pip install certifi - - name: Test - run: | - coverage run --parallel-mode --source wagtail runtests.py wagtail.search wagtail.documents wagtail.images --elasticsearch5 - env: - DATABASE_ENGINE: django.db.backends.sqlite3 - - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage-data + name: coverage-data-${{ github.job }}-${{ strategy.job-index }} path: .coverage.* + include-hidden-files: true - test-sqlite-elasticsearch7: + test-sqlite-elasticsearch8: runs-on: ubuntu-latest strategy: matrix: include: - - python: '3.9' - django: 'Django>=4.2,<4.3' + - python: '3.12' + django: 'Django>=5.1,<5.2' emailuser: emailuser steps: - name: Configure sysctl limits @@ -247,51 +237,53 @@ jobs: sudo sysctl -w vm.swappiness=1 sudo sysctl -w fs.file-max=262144 sudo sysctl -w vm.max_map_count=262144 - - uses: getong/elasticsearch-action@v1.2 + - uses: getong/elasticsearch-action@v1.3 with: - elasticsearch version: 7.6.1 + elasticsearch version: 8.8.0 host port: 9200 container port: 9200 host node port: 9300 node port: 9300 discovery type: 'single-node' - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e .[testing] + pip install -e '.[testing]' --config-settings editable_mode=strict pip install "${{ matrix.django }}" - pip install "elasticsearch>=7,<8" + pip install "elasticsearch>=8,<9" pip install certifi - name: Test run: | - coverage run --parallel-mode --source wagtail runtests.py wagtail.search wagtail.documents wagtail.images --elasticsearch7 + coverage run --parallel-mode --source wagtail runtests.py wagtail.search wagtail.documents wagtail.images --elasticsearch8 env: DATABASE_ENGINE: django.db.backends.sqlite3 USE_EMAIL_USER_MODEL: ${{ matrix.emailuser }} - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage-data + name: coverage-data-${{ github.job }}-${{ strategy.job-index }} path: .coverage.* + include-hidden-files: true - # https://github.com/getong/elasticsearch-action doesn't work for Elasticsearch 6, - # but https://github.com/elastic/elastic-github-actions does - test-postgres-elasticsearch6: + test-postgres-elasticsearch7: runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} strategy: matrix: include: - - python: '3.7' - django: 'Django>=3.2,<3.3' + - python: '3.9' + django: 'Django>=4.2,<4.3' + experimental: false services: postgres: - image: postgres:11 + image: postgres:latest env: POSTGRES_PASSWORD: postgres ports: @@ -307,23 +299,24 @@ jobs: sudo sysctl -w vm.max_map_count=262144 - uses: elastic/elastic-github-actions/elasticsearch@master with: - stack-version: 6.8.13 - - uses: actions/checkout@v3 + stack-version: 7.6.1 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip pip install "psycopg2>=2.6" - pip install -e .[testing] + pip install -e '.[testing]' --config-settings editable_mode=strict pip install "${{ matrix.django }}" - pip install "elasticsearch>=6,<7" + pip install "elasticsearch>=7,<8" pip install certifi - name: Test run: | - coverage run --parallel-mode --source wagtail runtests.py wagtail.search wagtail.documents wagtail.images --elasticsearch6 + coverage run --parallel-mode --source wagtail runtests.py wagtail.search wagtail.documents wagtail.images --elasticsearch7 env: DATABASE_ENGINE: django.db.backends.postgresql DATABASE_HOST: localhost @@ -331,30 +324,22 @@ jobs: DATABASE_PASSWORD: postgres USE_EMAIL_USER_MODEL: ${{ matrix.emailuser }} - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage-data + name: coverage-data-${{ github.job }}-${{ strategy.job-index }} path: .coverage.* + include-hidden-files: true - test-postgres-elasticsearch7: + test-sqlite-opensearch2: runs-on: ubuntu-latest continue-on-error: ${{ matrix.experimental }} strategy: matrix: include: - - python: '3.8' - django: 'Django>=4.1,<4.2' + - python: '3.10' + django: 'Django>=5.0,<5.1' experimental: false - services: - postgres: - image: postgres:11 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - name: Configure sysctl limits run: | @@ -362,53 +347,50 @@ jobs: sudo sysctl -w vm.swappiness=1 sudo sysctl -w fs.file-max=262144 sudo sysctl -w vm.max_map_count=262144 - - uses: elastic/elastic-github-actions/elasticsearch@master + - uses: ankane/setup-opensearch@v1 with: - stack-version: 7.6.1 - - uses: actions/checkout@v3 + opensearch-version: 2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install "psycopg2>=2.6" - pip install -e .[testing] + pip install -e '.[testing]' --config-settings editable_mode=strict pip install "${{ matrix.django }}" - pip install "elasticsearch>=7,<8" + pip install "elasticsearch==7.13.4" pip install certifi - name: Test run: | coverage run --parallel-mode --source wagtail runtests.py wagtail.search wagtail.documents wagtail.images --elasticsearch7 env: - DATABASE_ENGINE: django.db.backends.postgresql - DATABASE_HOST: localhost - DATABASE_USER: postgres - DATABASE_PASSWORD: postgres + DATABASE_ENGINE: django.db.backends.sqlite3 USE_EMAIL_USER_MODEL: ${{ matrix.emailuser }} - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage-data + name: coverage-data-${{ github.job }}-${{ strategy.job-index }} path: .coverage.* + include-hidden-files: true coverage: needs: - test-sqlite - test-postgres - test-mysql - - test-sqlite-elasticsearch5 - - test-sqlite-elasticsearch7 - - test-postgres-elasticsearch6 + - test-sqlite-elasticsearch8 - test-postgres-elasticsearch7 + - test-sqlite-opensearch2 runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' @@ -418,16 +400,29 @@ jobs: pip install coverage - name: Download coverage data - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: coverage-data + pattern: coverage-data-* + merge-multiple: true - name: Combine coverage data run: | coverage combine - coverage report -m --skip-covered + + - name: Generate coverage report + run: | + coverage report -m --skip-covered --skip-empty | sed 's/^/ /' >> $GITHUB_STEP_SUMMARY + coverage html --skip-covered --skip-empty + + - name: Upload HTML report as artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage_html_report + include-hidden-files: true - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: flags: backend + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 9519fac54f8f..fb915162ae48 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.pyc .DS_Store /.coverage +/.coverage.* /dist/ /build/ /MANIFEST @@ -17,6 +18,7 @@ npm-debug.log* /.cache/ /.pytest_cache/ /storybook-static +/wagtail/tests/test-media/ ### JetBrains .idea/ diff --git a/.nvmrc b/.nvmrc index 3c032078a4a2..209e3ef4b624 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 +20 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 715839536ef8..1b7612e1fcec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,56 +2,55 @@ default_language_version: node: system python: python3 repos: - - repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - language_version: python3 - args: ['--target-version', 'py37'] - - - repo: https://github.com/charliermarsh/ruff-pre-commit - # Ruff version. - rev: 'v0.0.261' + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.1.5' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.5.1 + rev: v3.1.0 hooks: - id: prettier types_or: [css, scss, javascript, ts, tsx, json, yaml] + additional_dependencies: + # Keep in sync with package.json + - prettier@3.1.0 - repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.8.0 + rev: v8.56.0 hooks: - id: eslint types: [file] files: \.(js|ts|tsx)$ args: [--report-unused-disable-directives] additional_dependencies: - - eslint@8.8.0 - - '@typescript-eslint/eslint-plugin@5.10.1' - - '@typescript-eslint/parser@5.10.1' + # Keep in sync with package.json + - eslint@8.56.0 + - '@typescript-eslint/eslint-plugin@6.21.0' + - '@typescript-eslint/parser@6.21.0' - '@wagtail/eslint-config-wagtail@0.4.0' - repo: https://github.com/thibaudcolas/pre-commit-stylelint - rev: v14.2.0 + rev: v15.11.0 hooks: - id: stylelint files: \.scss$ additional_dependencies: - - stylelint@14.2.0 - - '@wagtail/stylelint-config-wagtail@0.3.2' + # Keep in sync with package.json + - stylelint@15.11.0 + - '@wagtail/stylelint-config-wagtail@0.8.0' - repo: https://github.com/thibaudcolas/curlylint rev: v0.13.1 hooks: - id: curlylint args: ['--parse-only'] - repo: https://github.com/rtts/djhtml - rev: v1.5.2 + rev: 3.0.6 hooks: - id: djhtml - repo: https://github.com/returntocorp/semgrep - rev: v1.3.0 + rev: v1.40.0 hooks: - id: semgrep - args: ['--config', '.semgrep.yml', '--error'] + files: \.py$ + args: ['--config', '.semgrep.yml', '--disable-version-check', '--error'] diff --git a/.readthedocs.yml b/.readthedocs.yml index 369c645dcb82..c3d165f5e57a 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,4 +1,13 @@ +version: 2 +build: + os: ubuntu-22.04 + tools: + python: '3.11' python: - version: 3.7 - pip_install: true -requirements_file: null + install: + - method: pip + path: . + extra_requirements: + - docs +sphinx: + fail_on_warning: True diff --git a/.semgrep.yml b/.semgrep.yml index 3cba39ba5329..1fb28d213d60 100644 --- a/.semgrep.yml +++ b/.semgrep.yml @@ -39,6 +39,9 @@ rules: - metavariable-regex: metavariable: $STRING_ID regex: ".*%\\w.*" + paths: + exclude: + - 'wagtail/test/numberformat.py' message: > Do not use anonymous placeholders for translations. Use printf style formatting with named placeholders instead. diff --git a/.squash.yml b/.squash.yml index b7020f5a3a16..c6ca6764171b 100644 --- a/.squash.yml +++ b/.squash.yml @@ -1,26 +1,28 @@ deployments: default: - dockerimage: python:3.10.5-slim-buster + dockerimage: python:3.11.4-slim-bullseye build_steps: - apt-get update && apt-get install -y libssl-dev libpq-dev git build-essential libfontconfig1 libfontconfig1-dev curl - - RUN bash -c "curl -sL https://deb.nodesource.com/setup_18.x | bash -" + - RUN bash -c "curl -sL https://deb.nodesource.com/setup_20.x | bash -" - apt install -y nodejs - pip install setuptools pip --upgrade --force-reinstall - cd /code post_build_steps: - npm ci --audit=false --progress=false - npm run build + - git clone --depth=1 https://github.com/wagtail/bakerydemo.git /bakerydemo + # Install bakerydemo dependencies + - pip install -r /bakerydemo/requirements/base.txt + # Install the checked-out version of Wagtail, overriding whatever version was installed previously - pip install /code - - mkdir /myproject - - cd /myproject - - wagtail start mysite - - cd /myproject/mysite + - cd /bakerydemo - python manage.py migrate - - echo "

Wagtail test instance

Log into /admin/ with 'admin' / 'changeme'.

" > home/templates/home/welcome_page.html - - echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'admin@example.com', 'changeme')" | python manage.py shell - - echo "CSRF_TRUSTED_ORIGINS = ['https://*.squash.io']" >> mysite/settings/dev.py + # Load content, will also create a superuser for us (admin / changeme) + - python manage.py load_initial_data + # Ensure that the CSRF_TRUSTED_ORIGINS setting includes the Squash.io domain + - echo "CSRF_TRUSTED_ORIGINS = ['https://*.squash.io']" > /bakerydemo/bakerydemo/settings/local.py launch_steps: - - cd /myproject/mysite + - cd /bakerydemo/ - python manage.py runserver 0.0.0.0:80 port_forwarding: 80:80 run_options: -v ~/code:/code diff --git a/.stylelintrc.js b/.stylelintrc.js index 7b11d7db8d94..724ed605dbe6 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -22,12 +22,13 @@ module.exports = { ], // Would be valuable for strict BEM components but is too hard to enforce with legacy code. 'no-descending-specificity': null, - // Override stylelint-config-wagtail’s options to allow all float and clear values for now. - 'declaration-property-value-allowed-list': { - // 'clear': ['both', 'none'], - // 'float': ['inline-start', 'inline-end', 'none', 'unset'], - 'text-align': ['start', 'end', 'center'], - }, + // Refined ordering to align with media mixin usage - see https://github.com/wagtail/stylelint-config-wagtail/issues/37 + 'order/order': [ + 'dollar-variables', + 'custom-properties', + { type: 'at-rule', hasBlock: false }, // @-rules that have no nesting. + 'declarations', + ], // Some parts of declaration-strict-value commented out until we are in a position to enforce them. 'scale-unlimited/declaration-strict-value': [ [ @@ -47,6 +48,7 @@ module.exports = { // 'z-index', ], { + disableFix: true, ignoreValues: [ 'currentColor', 'inherit', @@ -78,5 +80,13 @@ module.exports = { ], }, ], + // Ignore rule until all existing selectors can be updated. + 'scss/selector-no-union-class-name': null, + // Ignore rule until all existing classes can be updated to use BEM. + 'selector-class-pattern': null, + // Allow more specificity until styles can be updated to match the more strict rules. + 'selector-max-specificity': '0,6,3', + // Ignore rule until we confirmed we prefer shorthand properties for positioning. + 'declaration-block-no-redundant-longhand-properties': null, }, }; diff --git a/.tx/config b/.tx/config index 8a9442fcc280..8b0e5e356e50 100644 --- a/.tx/config +++ b/.tx/config @@ -50,12 +50,6 @@ source_file = wagtail/locales/locale/en/LC_MESSAGES/django.po source_lang = en type = PO -[o:torchbox:p:wagtail:r:wagtailmodeladmin] -file_filter = wagtail/contrib/modeladmin/locale//LC_MESSAGES/django.po -source_file = wagtail/contrib/modeladmin/locale/en/LC_MESSAGES/django.po -source_lang = en -type = PO - [o:torchbox:p:wagtail:r:wagtailredirects] file_filter = wagtail/contrib/redirects/locale//LC_MESSAGES/django.po source_file = wagtail/contrib/redirects/locale/en/LC_MESSAGES/django.po diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 80a8ce572e63..291aacf7ff3d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,16 +1,899 @@ Changelog ========= -5.1 (xx.xx.xxxx) - IN DEVELOPMENT +6.3 (xx.xx.xxxx) - IN DEVELOPMENT +~~~~~~~~~~~~~~~~ + + * Add formal support for Django 5.1 (Matt Westcott) + * Formalize support for MariaDB (Sage Abdullah, Daniel Black) + * Redirect to the last viewed listing page after deleting form submissions (Matthias Brück) + * Provide `getTextLabel` method on date / time StreamField blocks (Vaughn Dickson) + * Purge frontend cache when modifying redirects (Jake Howard) + * Migrate workflow history views to universal listings (Sage Abdullah) + * Refactor documents views to use universal designs (Sage Abdullah) + * Refactor images views to use universal designs (Sage Abdullah) + * Implement universal listings for workflow usage and page type usage views (Sage Abdullah) + * Add search and filters to form pages listing (Sage Abdullah) + * Deprecate the `WAGTAIL_AUTO_UPDATE_PREVIEW` setting, use `WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL = 0` instead (Sage Abdullah) + * Consistently use `capfirst` for title-casing model verbose names (Sébastien Corbin) + * Fix: Prevent page type business rules from blocking reordering of pages (Andy Babic, Sage Abdullah) + * Fix: Improve layout of object permissions table (Sage Abdullah) + * Fix: Fix typo in aria-label attribute of page explorer navigation link (Sébastien Corbin) + * Fix: Reinstate transparency indicator on image chooser widgets (Sébastien Corbin) + * Fix: Remove table headers that have no text (Matt Westcott) + * Fix: Fix broken link to user search (Shlomo Markowitz) + * Fix: Ensure that JS slugify function strips Unicode characters disallowed by Django slug validation (Atif Khan) + * Fix: Do not show notices about root / unroutable pages when searching or filtering in the page explorer (Matt Westcott) + * Fix: Resolve contrast issue for page deletion warning (Sanjeev Holla S) + * Fix: Make sure content metrics falls back to body element only when intended (Sage Abdullah) + * Fix: Remove wrongly-added filters from redirects index (Matt Westcott) + * Fix: Prevent popular tags filter from generating overly complex queries when not filtering (Matt Westcott) + * Fix: Fix content path links in usage view to scroll to the correct element (Sage Abdullah) + * Fix: Always show the minimap toggle button (Albina Starykova) + * Docs: Upgrade Sphinx to 7.3 (Matt Westcott) + * Docs: Document how to customize date/time format settings (Vince Salvino) + * Docs: Create a new documentation section for deployment and move fly.io deployment from the tutorial to this section (Vince Salvino) + * Docs: Clarify process for UserViewSet customization (Sage Abdullah) + * Docs: Correct `WAGTAIL_WORKFLOW_REQUIRE_REAPPROVAL_ON_EDIT` documentation to state that it defaults to `False` (Matt Westcott) + * Docs: Add an example of customizing a default accessibility check (Cynthia Kiser) + * Maintenance: Removed support for Python 3.8 (Matt Westcott) + * Maintenance: Drop pytz dependency in favour of `zoneinfo.available_timezones` (Sage Abdullah) + * Maintenance: Relax django-taggit dependency to allow 6.0 (Matt Westcott) + * Maintenance: Improve page listing performance (Sage Abdullah) + * Maintenance: Phase out usage of SECRET_KEY in version and icon hashes (Jake Howard) + * Maintenance: Audit all use of localized and non-localized numbers in templates (Matt Westcott) + * Maintenance: Refactor StreamField `get_prep_value` for closer alignment with JSONField (Sage Abdullah) + * Maintenance: Move search implementation logic from generic `IndexView` to `BaseListingView` (Sage Abdullah) + * Maintenance: Upgrade Puppeteer integration tests for reliability (Matt Westcott) + * Maintenance: Restore ability to use `.in_bulk()` on specific querysets under Django 5.2a0 (Sage Abdullah) + * Maintenance: Add generated `test-media` to .gitignore (Shlomo Markowitz) + * Maintenance: Improve `debounce` util's return type for better TypeScript usage (Sage Abdullah) + * Maintenance: Ensure the side panel's show event is dispatched after any hide events (Sage Abdullah) + * Maintenance: Migrate preview-panel JavaScript to Stimulus & TypeScript, add full unit testing (Sage Abdullah) + * Maintenance: Move `wagtailConfig` values from inline scripts to the `wagtail_config` template tag (LB (Ben) Johnston, Sage Abdullah) + * Maintenance: Deprecate the `{% locales %}` and `{% js_translation_strings %}` template tags (LB (Ben) Johnston, Sage Abdullah) + + +6.2.2 (24.09.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Fix various instances of `USE_THOUSAND_SEPARATOR` formatting numbers where formatting is invalid (Sébastien Corbin, Matt Westcott) + * Fix: Fix broken link to user search (Shlomo Markowitz) + * Fix: Make sure content metrics falls back to body element only when intended (Sage Abdullah) + * Fix: Remove wrongly-added filters from redirects index (Matt Westcott) + * Fix: Prevent popular tags filter from generating overly complex queries when not filtering (Matt Westcott) + * Docs: Clarify process for UserViewSet customization (Sage Abdullah) + + +6.2.1 (20.08.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Handle `child_block` being passed as a kwarg in ListBlock migrations (Matt Westcott) + * Fix: Fix broken task type filter in workflow task chooser modal (Sage Abdullah) + * Fix: Prevent circular imports between `wagtail.admin.models` and custom user models (Matt Westcott) + * Fix: Ensure that concurrent editing check works for users who only have edit access via workflows (Matt Westcott) + + +6.2 (01.08.2024) +~~~~~~~~~~~~~~~~ + + * Optimize and consolidate redirects report view into the index view (Jake Howard, Dan Braghis) + * Support a `HOSTNAMES` parameter on `WAGTAILFRONTENDCACHE` to define which hostnames a backend should respond to (Jake Howard, sponsored by Oxfam America) + * Refactor redirects edit view to use the generic `EditView` and breadcrumbs (Rohit Sharma) + * Allow custom permission policies on snippets to prevent superusers from creating or editing them (Sage Abdullah) + * Do not link to edit view from listing views if user has no permission to edit (Sage Abdullah) + * Allow access to snippets and other model viewsets to users with "View" permission (Sage Abdullah) + * Skip `ChooseParentView` if only one possible valid parent page is available (Matthias Brück) + * Add `copy_for_translation_done` signal when a page is copied for translation (Arnar Tumi Þorsteinsson) + * Remove reduced opacity for draft page title in listings (Inju Michorius) + * Adopt more compact representation for StreamField definitions in migrations (Matt Westcott) + * Implement a new design for locale labels in listings (Albina Starykova) + * Add alt text validation rule in the accessibility checker (Albina Starykova) + * Add a `deactivate()` method to `ProgressController` (Alex Morega) + * Allow manually specifying credentials for CloudFront frontend cache backend (Jake Howard) + * Automatically register permissions for models registered with a `ModelViewSet` (Sage Abdullah) + * Implement universal listings UI for report views (Sage Abdullah) + * Make `routable_resolver_match` attribute available on RoutablePageMixin responses (Andy Chosak) + * Support customizations to `UserViewSet` via the app config (Sage Abdullah) + * Add word count and reading time metrics within the page editor (Albina Starykova. Sponsored by The Motley Fool) + * Implement a new design for accessibility checks (Albina Starykova) + * Allow changing available privacy options per page model (Shlomo Markowitz) + * Add concurrent editing notifications for pages and snippets (Matt Westcott, Sage Abdullah) + * Add "soft" client-side validation for `StreamBlock` / `ListBlock` `min_num` / `max_num` (Matt Westcott) + * Log accessibility checker results in the console to help developers with troubleshooting (Thibaud Colas) + * Disable pointer events on checker highlights to simplify DevTools inspections (Thibaud Colas) + * Fix: Make `WAGTAILIMAGES_CHOOSER_PAGE_SIZE` setting functional again (Rohit Sharma) + * Fix: Enable `richtext` template tag to convert lazy translation values (Benjamin Bach) + * Fix: Ensure permission labels on group permissions page are translated where available (Matt Westcott) + * Fix: Preserve whitespace in comment replies (Elhussein Almasri) + * Fix: Address layout issues in the title cell of universal listings (Sage Abdullah) + * Fix: Support SVG icon id attributes with single quotes in the styleguide (Sage Abdullah) + * Fix: Do not show delete button on model edit views if per-instance permissions prevent deletion (Matt Westcott) + * Fix: Remove duplicate header in privacy dialog when a privacy setting is set on a parent page or collection (Matthias Brück) + * Fix: Allow renditions of `.ico` images (Julie Rymer) + * Fix: Handle choice groups as dictionaries in active filters (Sébastien Corbin) + * Fix: Add separators when displaying multiple error messages on a StructBlock (Kyle Bayliss) + * Fix: Specify `verbose_name` on `TranslatableMixin.locale` so that it is translated when used as a label (Romein van Buren) + * Fix: Disallow null characters in API filter values (Jochen Wersdörfer) + * Fix: Fix image preview when Willow optimizers are enabled (Alex Tomkins) + * Fix: Ensure external-to-internal link conversion works when the `wagtail_serve` view is on a non-root path (Sage Abdullah) + * Fix: Add missing `for_instance` method to `PageLogEntryManager` (Matt Westcott) + * Fix: Ensure that "User" column on history view is translatable (Romein van Buren) + * Fix: Handle StreamField migrations where the field value is null (Joshua Munn) + * Fix: Prevent incorrect menu ordering when order value is 0 (Ben Dickinson) + * Fix: Fix dynamic image serve view with certain backends (Sébastien Corbin) + * Fix: Show not allowed extension in error message (Sahil Jangra) + * Fix: Fix focal point chooser when localization enabled (Sébastien Corbin) + * Fix: Ensure that system checks for `WAGTAIL_DATE_FORMAT`, `WAGTAIL_DATETIME_FORMAT` and `WAGTAIL_TIME_FORMAT` take `FORMAT_MODULE_PATH` into account (Sébastien Corbin) + * Fix: Prevent rich text fields inside choosers from being duplicated when opened repeatedly (Sage Abdullah) + * Docs: Remove duplicate section on frontend caching proxies from performance page (Jake Howard) + * Docs: Document `restriction_type` field on PageViewRestriction (Shlomo Markowitz) + * Docs: Document Wagtail's bug bounty policy (Jake Howard) + * Docs: Fix incorrect Sphinx-style code references to use MyST style (Byron Peebles) + * Docs: Document the fact that `Orderable` is not required for inline panels (Bojan Mihelac) + * Docs: Add note about `prefers-reduced-motion` to the accessibility documentation (Roel Koper) + * Docs: Update deployment instructions for Fly.io (Jeroen de Vries) + * Docs: Add better docs for generating URLs on creating admin views (Shlomo Markowitz) + * Docs: Document the `vary_fields` property for custom image filters (Daniel Kirkham) + * Docs: Fix documentation build errors (Himanshu Garg, Chris Shenton) + * Docs: Fix PDF export (Nathanaël Jourdane) + * Maintenance: Use `DjangoJSONEncoder` instead of custom `LazyStringEncoder` to serialize Draftail config (Sage Abdullah) + * Maintenance: Refactor image chooser pagination to check `WAGTAILIMAGES_CHOOSER_PAGE_SIZE` at runtime (Matt Westcott) + * Maintenance: Exclude the `client/scss` directory in Tailwind content config to speed up CSS compilation (Sage Abdullah) + * Maintenance: Split `contrib.frontend_cache.backends` into dedicated sub-modules (Andy Babic) + * Maintenance: Remove unused `docs/autobuild.sh` script (Sævar Öfjörð Magnússon) + * Maintenance: Replace `urlparse` with `urlsplit` to improve performance (Jake Howard) + * Maintenance: Optimize embed finder lookups (Jake Howard) + * Maintenance: Improve performance of initial admin loading by moving sprite hashing out of module import time (Jake Howard) + * Maintenance: Remove workaround and inline scripts for activating workflow actions (Sage Abdullah) + * Maintenance: Prevent `'BlockWidget' object has no attribute '_block_json'` from masking errors during StreamField serialization (Matt Westcott) + + +6.1.3 (11.07.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: CVE-2024-39317: Regular expression denial-of-service via search query parsing (Jake Howard) + * Fix: Allow renditions of `.ico` images (Julie Rymer) + * Fix: Handle choice groups as dictionaries in active filters (Sébastien Corbin) + * Fix: Fix image preview when Willow optimizers are enabled (Alex Tomkins) + * Fix: Fix dynamic image serve view with certain backends (Sébastien Corbin) + + +6.1.2 (30.05.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Fix client-side handling of select inputs within `ChoiceBlock` (Matt Westcott) + * Fix: Support SVG icon id attributes with single quotes in the styleguide (Sage Abdullah) + * Fix: CVE-2024-35228: Improper handling of insufficient permissions in `wagtail.contrib.settings` (Victor Miti, Matt Westcott, Jake Howard) + + +6.1.1 (21.05.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Fix form action URL in user edit and delete views for custom user models (Sage Abdullah) + * Fix: Fix snippet copy view not prefilling form data (Sage Abdullah) + * Fix: Address layout issues in the title cell of universal listings (Sage Abdullah) + * Fix: Fix incorrect rich text to HTML conversion when multiple link / embed types are present (Andy Chosak, Matt Westcott) + * Fix: Restore ability for custom widgets in StreamField blocks to have multiple top-level nodes (Sage Abdullah, Matt Westcott) + + +6.1 (01.05.2024) +~~~~~~~~~~~~~~~~ + + * Refine wording of page & collection privacy using password is a shared password and should not be used for secure content (Rohit Sharma, Jake Howard) + * Add RelatedObjectsColumn to the table UI framework (Matt Westcott) + * Reduce memory usage when rebuilding search indexes (Jake Howard) + * Support creating images in .ico format (Jake Howard) + * Add the ability to disable the usage of a shared password for enhanced security for the private pages and collections (documents) feature (Salvo Polizzi, Jake Howard) + * Add system checks to ensure that `WAGTAIL_DATE_FORMAT`, `WAGTAIL_DATETIME_FORMAT`, `WAGTAIL_TIME_FORMAT` are correctly configured (Rohit Sharma, Coen van der Kamp) + * Allow custom permissions with the same prefix as built-in permissions (Sage Abdullah) + * Allow displaying permissions linked to the Admin model's content type (Sage Abdullah) + * Add support for Draftail's JavaScript to use chooserUrls provided by entity options & for the Draftail widget to encode lazy URLs/ translations (Elhussein Almasri) + * Reimplement search promotions `IndexView` using the `generic.IndexView` (Rohit Sharma, Sage Abdullah, Storm Heg) + * Reimplement redirects `IndexView` using the `generic.IndexView` (Rohit Sharma, Sage Abdullah, Temidayo Azeez) + * Add `PageListingViewSet` for custom per-page-type page listings (Matt Westcott) + * Add `ChooseParentView` to `PageListingViewSet` to allow creating pages from custom page listings (Abdelrahman Hamada, Sage Abdullah) + * Implement new universal listings design for image listing view (Sage Abdullah) + * Implement new universal listings design for document listing view (Sage Abdullah) + * Implement new universal listings design for site and locale listing views (Sage Abdullah) + * Implement new universal listings design for page and snippet history view (Sage Abdullah) + * Implement new universal listings design for form builder submissions view (Sage Abdullah) + * Implement new universal listings design for collections listing view (Sage Abdullah) + * Implement new universal listings design for groups views (Sage Abdullah) + * Implement new universal listings design for users views (Sage Abdullah) + * Implement new universal listings design for workflow and task views (Sage Abdullah) + * Refine slim header button style to match designs (Sage Abdullah) + * Add simple admin keyboard shortcuts overview dialog, available in the help sub-menu (Karthik Ayangar, Rohit Sharma) + * Add ability to bulk toggle permissions in the user group editing view, including shift+click for multiple selections (LB (Ben) Johnston, Kalob Taulien) + * Update the minimum version of `djangorestframework` to 3.15.1 (Sage Abdullah) + * Add support for related fields in generic `IndexView.list_display` (Abdelrahman Hamada) + * Improve page fetching logic and cache route results per request (Gordon Pendleton) + * Optimize rewriting of links / embeds in rich text using bulk database lookups (Andy Chosak) + * Add normalization mechanism to StreamField so that assignments and defaults can be passed in a wider range of data types (Joshua Munn, Matt Westcott) + * Allow specifying a `STORAGES` alias name for `WAGTAILIMAGES_RENDITION_STORAGE` (Alec Baron) + * Update `PASSWORD_REQUIRED_TEMPLATE` setting to `WAGTAIL_PASSWORD_REQUIRED_TEMPLATE` with deprecation of previous naming (Saksham Misra, LB (Ben) Johnston) + * Update `DOCUMENT_PASSWORD_REQUIRED_TEMPLATE` setting to `WAGTAILDOCS_PASSWORD_REQUIRED_TEMPLATE` with deprecation of previous naming (Saksham Misra, LB (Ben) Johnston) + * When editing settings (contrib) use the same icon in the editing view that was declared when registering the setting (Vince Salvino, Rohit Sharma) + * Populate django-treebeard cache during page routing to improve performance of `get_parent` (Nigel van Keulen) + * Add a new user profile preference to configure user interface information density (Thibaud Colas) + * Add additional field types to Elasticsearch mapping (scott-8) + * Fix: CVE-2024-32882: Permission check bypass when editing a model with per-field restrictions through `wagtail.contrib.settings` or `ModelViewSet` (Ben Morse, Joshua Munn, Jake Howard, Sage Abdullah) + * Fix: Fix typo in `__str__` for MySQL search index (Jake Howard) + * Fix: Ensure that unit tests correctly check for migrations in all core Wagtail apps (Matt Westcott) + * Fix: Correctly handle `date` objects on `human_readable_date` template tag (Jhonatan Lopes) + * Fix: Ensure re-ordering buttons work correctly when using a nested InlinePanel (Adrien Hamraoui) + * Fix: Consistently remove model's `verbose_name` in group edit view when listing custom permissions (Sage Abdullah, Neeraj Yetheendran, Omkar Jadhav) + * Fix: Resolve issue local development of docs when running `make livehtml` (Sage Abdullah) + * Fix: Resolve issue with unwanted padding in chooser modal listings (Sage Abdullah) + * Fix: Ensure form builder emails that have date or datetime fields correctly localize dates based on the configured `LANGUAGE_CODE` (Mark Niehues) + * Fix: Ensure the Stimulus `UnsavedController` checks for nested removal/additions of inputs so that the unsaved warning shows in more valid cases when editing a page (Karthik Ayangar) + * Fix: Ensure `get_add_url()` is always used to re-render the add button when the listing is refreshed in viewsets (Sage Abdullah) + * Fix: Ensure dropdown content cannot get higher than the viewport and add scrolling within content if needed (Chiemezuo Akujobi) + * Fix: Prevent snippets model index view from crashing when a model does not have an `objects` manager (Jhonatan Lopes) + * Fix: Fix `get_dummy_request`'s resulting host name when running tests with `ALLOWED_HOSTS = ["*"]` (David Buxton) + * Fix: Fix timezone handling in the `timesince_last_update` template tag (Matt Westcott) + * Fix: Fix Postgres phrase search to respect the language set in settings (Ihar Marhitych) + * Fix: Retain query parameters when switching between locales in the page chooser (Abdelrahman Hamada, Sage Abdullah) + * Fix: Add `w-kbd-scope-value` with support for `global` so that specific keyboard shortcuts (e.g. ctrl+s/cmd+s) trigger consistently even when focused on fields (Neeraj Yetheendran) + * Fix: Improve exception handling when generating image renditions concurrently (Andy Babic) + * Fix: Respect `WAGTAIL_ALLOW_UNICODE_SLUGS` setting when auto-generating slugs (LB (Ben) Johnston) + * Fix: Use correct URL when redirecting back to page search results after an AJAX search (Sage Abdullah) + * Fix: Reinstate missing static files in style guide (Sage Abdullah) + * Fix: Provide `convert_mariadb_uuids` management command to assist with upgrading to Django 5.0+ on MariaDB (Matt Westcott) + * Docs: Add contributing development documentation on how to work with a fork of Wagtail (Nix Asteri, Dan Braghis) + * Docs: Make sure the settings panel is listed in tabbed interface examples (Tibor Leupold) + * Docs: Update content and page names to their US spelling instead of UK spelling (Victoria Poromon) + * Docs: Update broken and incorrect links throughout the documentation (EK303) + * Docs: Fix formatting of `--purge-only` in `wagtail_update_image_renditions` management command section (Pranith Beeram) + * Docs: Update template components documentation to better explain the usage of the Laces library (Tibor Leupold) + * Docs: Update Sphinx theme to `6.3.0` with a fix for the missing favicon (Sage Abdullah) + * Docs: Document risk of XSS attacks on document upload (Matt Westcott, with thanks to Georgios Roumeliotis of TwelveSec for the original report) + * Docs: Add clarity to how custom StreamField validation works (Tibor Leupold) + * Docs: Add additional reference to the `wagtail_update_image_renditions` management command on the using images page (LB (Ben) Johnston) + * Docs: Correct information about line endings in Window development docs (Sage Abdullah) + * Docs: Improve code snippets for "Create a footer for all pages" tutorial section (Drikus Roor) + * Docs: Update list of third-party tutorials (LB (Ben) Johnston) + * Docs: Update "Integrating into Django" documentation to emphasise creating page models (Matt Westcott) + * Maintenance: Move RichText HTML whitelist parser to use the faster, built in `html.parser` (Jake Howard) + * Maintenance: Remove duplicate 'path' in default_exclude_fields_in_copy (Ramchandra Shahi Thakuri) + * Maintenance: Update unit tests to always use the faster, built in `html.parser` & remove `html5lib` dependency (Jake Howard) + * Maintenance: Adjust Eslint rules for TypeScript files (Karthik Ayangar) + * Maintenance: Rename the React `Button` that only renders links (a element) to `Link` and remove unused prop & behavior that was non-compliant for aria role usage (Advik Kabra) + * Maintenance: Set up an `wagtail.models.AbstractWorkflow` model to support future customizations around workflows (Hossein) + * Maintenance: Improve `classnames` template tag to handle nested lists of strings, use template tag for admin `body` element (LB (Ben) Johnston) + * Maintenance: Merge `UploadedDocument` and `UploadedImage` into new `UploadedFile` model for easier shared code usage (Advik Kabra, Karl Hobley) + * Maintenance: Optimize queries in dashboard panels (Sage Abdullah) + * Maintenance: Optimize queries in group create/edit view (Sage Abdullah) + * Maintenance: Move modal-workflow.js script usage to base admin template instead of ad-hoc imports (Elhussein Almasri) + * Maintenance: Update all Draftail chooserUrls to be passed in via the Entity options instead of using `window.chooserUrls` globals, removing the need for inline scripts (Elhussein Almasri) + * Maintenance: Enhance `w-init` (InitController) to support a `detail` value to be dispatched on events (Chiemezuo Akujobi) + * Maintenance: Remove usage of inline scripts and instead use event dispatching to instantiate standalone Draftail editor instances (Chiemezuo Akujobi) + * Maintenance: Refactor `page_breadcrumbs` tag to use shared `breadcrumbs.html` template (Sage Abdullah) + * Maintenance: Add `keyboard` icon to admin icon set (Rohit Sharma) + * Maintenance: Remove dead code in the minimap when elements are not found (LB (Ben) Johnston) + * Maintenance: Ensure untrusted data sources are logged correctly in the Stimulus `SwapController` (LB (Ben) Johnston) + * Maintenance: Update Wagtail logo in admin sidebar & favicon plus documentation to the latest version (Osaf AliSayed, Albina Starykova, LB (Ben) Johnston) + * Maintenance: Remove usage of inline scripts and instead use a new Stimulus controller (`w-block`/`BlockController`) to instantiate `StreamField` blocks (Karthik Ayangar) + * Maintenance: Update NPM Babel, TypeScript and Webpack packages (Neeraj Yetheendran) + * Maintenance: Replace ad-hoc JavaScript and vendor Mousetrap usage to a new Stimulus controller (`w-kbd`/`KeyboardController`) (Neeraj Yetheendran) + * Maintenance: Update django-filter to 24.x (Sebastian Muthwill) + * Maintenance: Remove jQuery usage in telepath widget classes (Matt Westcott) + * Maintenance: Remove `xregexp` (IE11 polyfill) along with `window.XRegExp` global util (LB (Ben) Johnston) + * Maintenance: Refactor the Django port of `urlify` to use TypeScript, officially deprecate `window.URLify` global util (LB (Ben) Johnston) + + +6.0.6 (11.07.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: CVE-2024-39317: Regular expression denial-of-service via search query parsing (Jake Howard) + + +6.0.5 (30.05.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: CVE-2024-35228: Improper handling of insufficient permissions in `wagtail.contrib.settings` (Victor Miti, Matt Westcott, Jake Howard) + + +6.0.4 (21.05.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Fix snippet copy view not prefilling form data (Sage Abdullah) + + +6.0.3 (01.05.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: CVE-2024-32882: Permission check bypass when editing a model with per-field restrictions through `wagtail.contrib.settings` or `ModelViewSet` (Ben Morse, Joshua Munn, Jake Howard, Sage Abdullah) + * Fix: Respect `WAGTAIL_ALLOW_UNICODE_SLUGS` setting when auto-generating slugs (LB (Ben) Johnston) + * Fix: Use correct URL when redirecting back to page search results after an AJAX search (Sage Abdullah) + * Fix: Reinstate missing static files in style guide (Sage Abdullah) + * Fix: Provide `convert_mariadb_uuids` management command to assist with upgrading to Django 5.0+ on MariaDB (Matt Westcott) + * Fix: Fix generic CopyView for models with primary keys that need to be quoted (Sage Abdullah) + + +6.0.2 (03.04.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Ensure that modal tabs width are not impacted by side panel opening (LB (Ben) Johnston) + * Fix: Resolve issue local development of docs when running `make livehtml` (Sage Abdullah) + * Fix: Resolve issue with unwanted padding in chooser modal listings (Sage Abdullah) + * Fix: Ensure `get_add_url()` is always used to re-render the add button when the listing is refreshed in viewsets (Sage Abdullah) + * Fix: Move `modal-workflow.js` script usage to base admin template instead of ad-hoc imports so that choosers work in `ModelViewSet`s (Elhussein Almasri) + * Fix: Ensure JavaScript for common widgets such as `InlinePanel` is included by default in `ModelViewSet`'s create and edit views (Sage Abdullah) + * Fix: Reinstate styles for customizations of `extra_footer_actions` block in page create/edit templates (LB (Ben) Johnston, Sage Abdullah) + * Fix: Prevent crash when loading an empty table block in the editor (Sage Abdullah) + * Docs: Update Sphinx theme to `6.3.0` with a fix for the missing favicon (Sage Abdullah) + + +6.0.1 (15.02.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Ensure `BooleanRadioSelect` uses the same styles as `RadioSelect` (Thibaud Colas) + * Fix: Prevent failure on `collectstatic` when `ManifestStaticFilesStorage` is in use (Matt Westcott) + * Fix: Prevent error on submitting an empty search in the admin under Elasticsearch (Maikel Martens) + + +6.0 (07.02.2024) +~~~~~~~~~~~~~~~~ + + * Added support for Django 5.0 + * Implemented universal listings – a unified listing and filtering interface for Pages, Snippets, Forms (Ben Enright, Matt Westcott, Thibaud Colas, Sage Abdullah) + * Add the accessibility checker within the page and snippets editor (Thibaud Colas) + * Added `search_index` option to StreamField blocks to control whether the block is indexed for searching (Vedant Pandey) + * Remember previous location on returning from page add/edit actions (Robert Rollins) + * Update settings file in project settings to address Django 4.2 deprecations (Sage Abdullah) + * Improve layout and accessibility of the image URL generator page, reduce reliance on JavaScript (Temidayo Azeez) + * Allow `UniqueConstraint` in place of `unique_together` for `TranslatableMixin`'s system check (Temidayo Azeez, Sage Abdullah) + * Make use of `IndexView.get_add_url()` in snippets index view template (Christer Jensen, Sage Abdullah) + * Allow `Page.permissions_for_user()` to be overridden by specific page types (Sébastien Corbin) + * Improve visual alignment of explore icon in Page listings for longer content (Krzysztof Jeziorny) + * Add `extra_actions` blocks to Snippets and generic index templates (Bhuvnesh Sharma) + * Added page types usage report (Jhonatan Lopes) + * Add support for defining `panels` / `edit_handler` on `ModelViewSet` (Sage Abdullah) + * Use a single instance of `PagePermissionPolicy` in `wagtail.permissions` module (Sage Abdullah) + * Add max tag length validation for multiple uploads (documents/images) (Temidayo Azeez) + * Ensure expanded side panel does not overlap form content for most viewports (Chiemezuo Akujobi) + * Add ability to modify the default ordering for the page explorer view (Shlomo Markowitz) + * Remove overly verbose image captions in image listings for screen readers (Sage Abdullah) + * Ensure screen readers and dictation tools can more easily navigate bulk actions in images, documents and page listings by streamlining labels and descriptions (Sage Abdullah) + * Remove support for Safari 14 (Thibaud Colas) + * Add ability to click to copy the URL in the image URL generator page (Sai Srikar Dumpeti) + * Add ability to filter by page type and date updated in the page listing view (Matt Westcott) + * Add ability to filter by owner and site in the page listing view (Matt Westcott) + * Improve right-to-left support by using flow-relative float styles (Thibaud Colas) + * Improve right-to-left support by mirroring Wagtail icons as needed (Sage Abdullah) + * Add support for mirroring third-party icons added in Wagtail (Sage Abdullah) + * Show edit as a main action in generic history and usage views (Sage Abdullah) + * Make styles for header buttons consistent (Sage Abdullah) + * Improve styles of slim header's search and filters (Sage Abdullah) + * Change page listing's add button to icon-only (Sage Abdullah) + * Add sublabel to breadcrumbs, including history, usage, and inspect views (Sage Abdullah) + * Standardise search form placeholder to 'Search…' (Sage Abdullah) + * Use SlugInput on all SlugFields by default (LB (Ben) Johnston) + * Show character counts on RichTextBlock with `max_length` (Elhussein Almasri) + * Move locale selector in generic IndexView to a filter (Sage Abdullah) + * Add ability to customize a page's copy form (Neeraj Yetheendran) + * Add optional caption field to `TypedTableBlock` (Tommaso Amici, Cynthia Kiser) + * Switch the `TableBlock` header controls to a field that requires user input (Bhuvnesh Sharma, Aman Pandey, Cynthia Kiser) + * Add `WAGTAILADMIN_LOGIN_URL` setting to allow customizing the login URL (Neeraj Yetheendran) + * Replace legacy dropdown component with new Tippy dropdown-button (Thibaud Colas) + * Add ability to filter by existence of child pages in the page listing view (Matt Westcott) + * Polish dark theme styles and update color tokens (Thibaud Colas, Rohit Sharma) + * Keep database state of pages and snippets updated while in draft state (Stefan Hammer) + * Add `DrilldownController` and `w-drilldown` component to support drilldown menus (Thibaud Colas) + * Add support for `caption` on admin UI Table component (Aman Pandey) + * Add API support for a redirects (contrib) endpoint (Rohit Sharma, Jaap Roes, Andreas Donig) + * Add the default ability for all `SnippetViewSet` & `ModelViewSet` to support being copied (Shlomo Markowitz) + * Support dynamic Wagtail guide links in the admin that are based on the running version of Wagtail (Tidiane Dia) + * Added `AbstractGroupApprovalTask` to simplify customizing behavior of custom `Task` models (John-Scott Atlakson) + * Fix: Update system check for overwriting storage backends to recognize the `STORAGES` setting introduced in Django 4.2 (phijma-leukeleu) + * Fix: Prevent password change form from raising a validation error when browser autocomplete fills in the "Old password" field (Chiemezuo Akujobi) + * Fix: Ensure that the legacy dropdown options, when closed, do not get accidentally clicked by other interactions on wide viewports (CheesyPhoenix, Christer Jensen) + * Fix: Add a fallback background for the editing preview iframe for sites without a background (Ian Price) + * Fix: Preserve whitespace in rendered comments (Elhussein Almasri) + * Fix: Remove search logging from project template so that new projects without the search promotions module will not error (Matt Westcott) + * Fix: Ensure text-only email notifications for updated comments do not escape HTML characters (Rohit Sharma) + * Fix: Use the latest draft when copying an unpublished page for translation (Andrey Nehaychik) + * Fix: Make Workflow and Aging Pages reports only available to users with page-related permissions (Rohit Sharma) + * Fix: Make searching on specific fields work correctly on Elasticsearch when boost is in use (Matt Westcott) + * Fix: Use a visible border and background color to highlight active formatting in the rich text toolbar (Cassidy Pittman) + * Fix: Ensure image focal point box can be removed (Gunnar Scherf) + * Fix: Ensure that Snippets search results correctly use the `index_results.html` or `index_results_template_name` override on initial load (Stefan Hammer) + * Fix: Avoid error when attempting to moderate a page drafted by a now deleted user (Dan Braghis) + * Fix: Do not show multiple error messages when editing a Site to use existing hostname and port (Rohit Sharma) + * Fix: Avoid error when exporting Aging Pages report where a page has an empty `last_published_by_user` (Chiemezuo Akujobi) + * Fix: Ensure Page querysets support using `alias` and `specific` (Tomasz Knapik) + * Fix: Ensure workflow dashboard panels work when the page/snippet is missing (Sage Abdullah) + * Fix: Ensure `ActionController` explicitly checks for elements that allow select functionality (Nandini Arora) + * Fix: Prevent a ValueError with `FormSubmissionsPanel` on Django 5.0 when creating a new form page (Matt Westcott) + * Fix: Avoid duplicate entries in "Recent edits" panel when copying pages (Matt Westcott) + * Fix: Prevent TitleFieldPanel from raising an error when the slug field is missing or read-only (Rohit Sharma) + * Fix: Ensure that the close button on the new dialog designs is visible in the non-message variant (Nandini Arora) + * Fix: Ensure the sidebar account toggle has no duplicate accessible labels (Nandini Arora) + * Fix: Avoid text overflow issues in comment replies and scroll position issues for long comments (Rohit Sharma) + * Fix: Ensure that page listing re-ordering messages and accessible labels can be translated (Aman Pandey, LB (Ben) Johnston) + * Fix: Resolve multiple issues with page listing re-ordering using keyboard and screen readers (Aman Pandey) + * Fix: Remove 'Page' from page types filter on aging pages report (Matt Westcott) + * Fix: Prevent page types filter from showing other non-Page models that match by name (Matt Westcott) + * Fix: Ensure `MultipleChooserPanel` modal works correctly when `USE_THOUSAND_SEPARATOR` is `True` for pages with ids over 1,000 (Sankalp, Rohit Sharma) + * Fix: When using an empty table header (`th`) for visual spacing, ensure this is ignored by accessibility tooling (V Rohitansh) + * Fix: Ensure the panel anchor button sizes meet accessibility guidelines for minimum dimensions (Nandini Arora) + * Fix: Raise a 404 for bulk actions for models that don't exist instead of throwing a 500 error (Alex Tomkins) + * Fix: Raise a `SiteSetting.DoesNotExist` error when retrieving settings for an unrecognized site (Nick Smith) + * Fix: Ensure that defaulted or unique values declared in `exclude_fields_in_copy` are correctly excluded in new copies, resolving to the default value (Elhussein Almasri) + * Fix: Ensure that `default_ordering` set on IndexView is preserved if ModelViewSet does not specify an explicit ordering (Cynthia Kiser) + * Fix: Ensure that TableBlock cells are accessible when using keyboard control only (Elhussein Almasri) + * Fix: Resolve issue where clicking Publish for a Page that was in workflow in Safari would block publishing and not trigger the workflow confirmation modal (Alex Morega) + * Fix: Fix pagination links on model history and usage views (Matt Westcott) + * Fix: Fix crash when accessing workflow reports with a deleted snippet (Sage Abdullah) + * Docs: New developer tutorial (Damilola Oladele, Meagen Voss, Thibaud Colas) + * Docs: Document, for contributors, the use of translate string literals passed as arguments to tags and filters using `_()` within templates (Chiemezuo Akujobi) + * Docs: Document all features for the Documents app in one location (Neeraj Yetheendran) + * Docs: Add section to testing docs about creating pages and working with page content (Mariana Bedran Lesche) + * Docs: Add more nuance to the database recommendations in performance page (Jadesola Kareem) + * Docs: Add clarity that MultipleChooserPanel may require a chooser viewset and how the functionality is expected to work (Andy Chosak) + * Docs: Clarify where documentation build commands should be run (Nikhil S Kalburgi) + * Docs: Add missing import to tutorial BlogPage example (Salvo Polizzi) + * Docs: Update contributing guide documentation and GitHub templates to better support new contributors (Thibaud Colas) + * Docs: Add more CSS authoring guidelines (Thibaud Colas) + * Docs: Update MyST documentation parser library to 2.0.0 (Neeraj Yetheendran) + * Docs: Add documentation writing guidelines for intersphinx / external links (LB (Ben) Johnston) + * Docs: Add `Page` model reference `get_children` documentation (Salvo Polizzi) + * Docs: Enforce CI build checks for documentation so that malformed links or missing images will not be allowed (Neeraj Yetheendran) + * Docs: Update spelling on customizing admin template and page model section from British to American English (Victoria Poromon) + * Docs: Add documentation for how to override the file locations for custom image models via `get_upload_to` methods (Osaf AliSayed, Dharmik Gangani) + * Docs: Update documentation theme (Sphinx Wagtail Theme) to 6.2.0, fixing the incorrect favicon (LB (Ben) Johnston, Sahil Jangra) + * Docs: Refactor promotion banner without jQuery and use sameSite cookies when storing if cleared (LB (Ben) Johnston) + * Docs: Use cross-reference for compatible Python versions in tutorial instead of the out-of-date listing (mirusu400) + * Maintenance: Update BeautifulSoup upper bound to 4.12.x (scott-8) + * Maintenance: Migrate initialization of classes (such as `body.ready`) from multiple JavaScript implementations to one Stimulus controller `w-init` (Chiemezuo Akujobi) + * Maintenance: Adopt the usage of translate string literals using `arg=_('...')` in all `wagtailadmin` module templates (Chiemezuo Akujobi) + * Maintenance: Migrate the contrib styleguide index view to a class-based view (Chiemezuo Akujobi) + * Maintenance: Update djhtml to 3.0.6 (Matt Westcott) + * Maintenance: Migrate the contrib settings edit view to a class-based view (Chiemezuo Akujobi, Sage Abdullah) + * Maintenance: Remove django-pattern-library upper bound in testing dependencies (Sage Abdullah) + * Maintenance: Split up functions in Elasticsearch backend for easier extensibility (Marcel Kornblum, Cameron Lamb, Sam Dudley) + * Maintenance: Relax draftjs_exporter dependency to allow using version 5.x (Sylvain Fankhauser) + * Maintenance: Refine styling of listings, account settings panels and the block chooser (Meli Imelda) + * Maintenance: Remove icon font support (Matt Westcott) + * Maintenance: Remove deprecated SVG icons (Matt Westcott) + * Maintenance: Remove icon font styles (Thibaud Colas) + * Maintenance: Migrate account editing view to a class-based view (Kehinde Bobade) + * Maintenance: Upgrade frontend tooling to use Node 20 (LB (Ben) Johnston) + * Maintenance: Upgrade `ruff` and replace `black` with `ruff format` (John-Scott Atlakson) + * Maintenance: Update Willow upper bound to 2.x (Dan Braghis) + * Maintenance: Removed support for Django < 4.2 (Dan Braghis) + * Maintenance: Refactor page explorer index template to extend generic index template (Sage Abdullah) + * Maintenance: Replace template components implementation with standalone `laces` library (Tibor Leupold) + * Maintenance: Refactor snippets index view and template to make better use of generic IndexView (Sage Abdullah) + * Maintenance: Introduce an internal `{% formattedfield %}` tag to replace direct use of `wagtailadmin/shared/field.html` (Matt Westcott) + * Maintenance: Update Telepath dependency to 0.3.1 (Matt Westcott) + * Maintenance: Allow `ActionController` to have a `noop` method to more easily leverage standalone Stimulus action options (Nandini Arora) + * Maintenance: Upgrade to latest TypeScript and Storybook (Thibaud Colas, Sage Abdullah) + * Maintenance: Turn on `skipLibCheck` for TypeScript (LB (Ben) Johnston) + * Maintenance: Refactor documents listing view to use generic IndexView (Sage Abdullah) + * Maintenance: Support for the Stimulus `CloneController` to auto clear the added content after a set duration (LB (Ben) Johnston) + * Maintenance: Refactor images listing view to use generic IndexView (Sage Abdullah) + * Maintenance: Refactor form pages listing view to use generic IndexView (Sage Abdullah) + * Maintenance: Update Stylelint, our linting configuration, Sass, and related code changes (LB (Ben) Johnston) + * Maintenance: Simplify browserslist and browser support documentation (Thibaud Colas) + * Maintenance: Relax django-taggit dependency to allow 5.0 (Sylvain Fankhauser) + * Maintenance: Fix various warnings when building docs (Cynthia Kiser) + * Maintenance: Upgrade sphinxcontrib-spelling to 7.x for Python 3.12 compatibility (Matt Westcott) + * Maintenance: Move logic for django-filters filtering into `BaseListingView` (Matt Westcott) + * Maintenance: Remove or replace legacy CSS classes: visuallyhidden, visuallyvisible, divider-after, divider-before, inline, inline-block, block, u-hidden, clearfix, reordering, overflow (Thibaud Colas) + * Maintenance: Prevent future issues with icon.html end-of-file newlines (Thibaud Colas) + * Maintenance: Rewrite styles using legacy `c-`, `o-`, `u-`, `t-`, `is-` prefixes (Thibaud Colas) + * Maintenance: Remove invalid CSS styles / Sass selector concatenation (Thibaud Colas) + * Maintenance: Refactor listing views to share more queryset ordering logic (Matt Westcott) + * Maintenance: Remove `initTooltips` in favor of Stimulus controller (LB (Ben) Johnston) + * Maintenance: Enhance the Stimulus `InitController` to allow for custom event dispatching when ready (Aditya, LB (Ben) Johnston) + * Maintenance: Remove inline script usage for comment initialization and adopt an event listener/dispatch approach for better CSP compliance (Aditya, LB (Ben) Johnston) + * Maintenance: Migrate styleguide ad-hoc JavaScript to use styles only to avoid CSP issues (LB (Ben) Johnston) + * Maintenance: Update Jest version - frontend tooling (Nandini Arora) + * Maintenance: Remove non-functional and inaccessible auto-focus on first field in page create forms (LB (Ben) Johnston) + * Maintenance: Migrate the unsaved form checks & confirmation trigger to Stimulus `UnsavedController` (Sai Srikar Dumpeti, LB (Ben) Johnston) + * Maintenance: Reduce gap between snippets and generic views/templates (Sage Abdullah) + * Maintenance: Migrate page listing menu re-ordering (drag & drop) from jQuery inline scripts to `OrderableController` with a more accessible solution (Aman Pandey, LB (Ben) Johnston) + * Maintenance: Clean up scss variable usage, remove unused variables and mixins, adopt more core token variables (Jai Vignesh J, Nandini Arora, LB (Ben) Johnston) + * Maintenance: Migrate Image URL generator views to class-based views (Rohit Sharma) + * Maintenance: Use Django's `FileResponse` when serving files such as Images or Documents (Jake Howard) + * Maintenance: Deprecated `WidgetWithScript` base widget class (LB (Ben) Johnston) + * Maintenance: Remove support for Django 4.1 and below (Sage Abdullah) + + +5.2.6 (11.07.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: CVE-2024-39317: Regular expression denial-of-service via search query parsing (Jake Howard) + * Fix: Fix image preview when Willow optimizers are enabled (Alex Tomkins) + * Maintenance: Remove django-pattern-library upper bound in testing dependencies (Sage Abdullah) + + +5.2.5 (01.05.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Respect `WAGTAIL_ALLOW_UNICODE_SLUGS` setting when auto-generating slugs (LB (Ben) Johnston) + * Fix: Use correct URL when redirecting back to page search results after an AJAX search (Sage Abdullah) + * Fix: Provide `convert_mariadb_uuids` management command to assist with upgrading to Django 5.0+ on MariaDB (Matt Westcott) + + +5.2.4 (03.04.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Prevent TitleFieldPanel from raising an error when the slug field is missing or read-only (Rohit Sharma) + * Fix: Fix pagination links on model history and usage views (Matt Westcott) + * Fix: Fix crash when accessing workflow reports with a deleted snippet (Sage Abdullah) + * Fix: Prevent error on submitting an empty search in the admin under Elasticsearch (Maikel Martens) + + +5.2.3 (23.01.2024) +~~~~~~~~~~~~~~~~~~ + + * Fix: Prevent a ValueError with `FormSubmissionsPanel` on Django 5.0 when creating a new form page (Matt Westcott) + * Fix: Specify telepath 0.3.1 as the minimum supported version, for Django 5.0 compatibility (Matt Westcott) + + +5.2.2 (06.12.2023) +~~~~~~~~~~~~~~~~~~ + + * Added support for Django 5.0 + * Fix: Use a visible border and background color to highlight active formatting in the rich text toolbar (Cassidy Pittman) + * Fix: Ensure image focal point box can be removed (Gunnar Scherf) + * Fix: Ensure that Snippets search results correctly use the `index_results.html` or `index_results_template_name` override on initial load (Stefan Hammer) + * Fix: Avoid error when attempting to moderate a page drafted by a now deleted user (Dan Braghis) + * Fix: Ensure workflow dashboard panels work when the page/snippet is missing (Sage Abdullah) + * Fix: Prevent custom controls from stacking on top of the comment button in Draftail toolbar (Ben Morse) + + +5.2.1 (16.11.2023) +~~~~~~~~~~~~~~~~~~ + + * Fix: Add a fallback background for the editing preview iframe for sites without a background (Ian Price) + * Fix: Remove search logging from project template so that new projects without the search promotions module will not error (Matt Westcott) + * Fix: Ensure text only email notifications for updated comments do not escape HTML characters (Rohit Sharma) + * Fix: Use logical OR operator to combine search fields for Django ORM in generic IndexView (Varun Kumar) + * Fix: Ensure that explorer_results views fill in the correct next_url parameter on action URLs (Matt Westcott) + * Fix: Fix crash when accessing the history view for a translatable snippet (Sage Abdullah) + * Fix: Prevent upload of SVG images from failing when image feature detection is enabled (Joshua Munn) + * Fix: Fix crash when using the locale switcher on the snippets create view (Sage Abdullah) + * Fix: Fix performance regression on reports from calling `decorate_paginated_queryset` before pagination / filtering (Alex Tomkins) + * Fix: Make searching on specific fields work correctly on Elasticsearch when boost is in use (Matt Westcott) + * Fix: Prevent snippet permission post-migrate hook from failing on multiple database configurations (Joe Tsoi) + * Fix: Reinstate ability to filter on page type when searching on an empty query (Sage Abdullah) + * Fix: Prevent error on locked pages report when a user has locked multiple pages (Matt Westcott) + * Docs: Fix code example for `{% picture ... as ... %}` template tag (Rezyapkin) + + +5.2 LTS (01.11.2023) +~~~~~~~~~~~~~~~~~~~~ + + * Redesigned page listing view (Ben Enright, Matt Westcott, Thibaud Colas, Sage Abdullah) + * Support OpenSearch as an alternative to Elasticsearch (Matt Westcott) + * Add support for Python 3.12 (Matt Westcott) + * Add preview-aware and page-aware fragment caching template tags, `wagtailcache` & `wagtailpagecache` (Jake Howard) + * Always set help text element ID for form fields with help text in `field.html` template (Sage Abdullah) + * Move `SnippetViewSet` menu registration mechanism to base `ViewSet` class (Sage Abdullah) + * Enable reference index tracking for models registered with `ModelViewSet` (Sage Abdullah) + * When copying a page or creating an alias, copy its view restrictions to the destination (Sandeep Choudhary, Suyash Singh) + * Support pickling of StreamField values (pySilver) + * Move `SnippetViewSet` template override mechanism to `ModelViewSet` (Sage Abdullah) + * Move `SnippetViewSet.list_display` to `ModelViewSet` (Sage Abdullah) + * Remove `wagtail.publish` log action on aliases when they are created from live source pages or the source page is published (Dan Braghis) + * Remove `wagtail.unpublish` log action on aliases when source page is unpublished (Dan Braghis) + * Add compare buttons to workflow dashboard panel (Matt Westcott) + * Add the ability to use filters and to export listings in generic `IndexView` (Sage Abdullah) + * Move `list_filter`, `filterset_class`, `search_fields`, `search_backend_name`, `list_export`, `export_filename`, `list_per_page`, and `ordering` from `SnippetViewSet` to `ModelViewSet` (Sage Abdullah, Cynthia Kiser) + * Add default header titles to generic `IndexView` and `CreateView` (Sage Abdullah) + * Allow overriding `IndexView.export_headings` via `ModelViewSet` (Christer Jensen, Sage Abdullah) + * Support specifying a `get_object_list` method on `ChooserViewSet` (Matt Westcott) + * Add `linked_fields` mechanism on chooser widgets to allow choices to be limited by fields on the calling page (Matt Westcott) + * Add support for merging cells within `TableBlock` with the `mergedCells` option (Gareth Palmer) + * When adding a panel within `InlinePanel`, focus will now shift to that content similar to `StreamField` (Faishal Manzar) + * Show the full first published at date within a tooltip on the Page status sidebar on the relative date (Rohit Sharma) + * Extract generic breadcrumbs functionality from page breadcrumbs (Sage Abdullah) + * Add support for `placement` in the `human_readable_date` tooltip template tag (Rohit Sharma) + * Add breadcrumbs support to custom `ModelViewSet` views (Sage Abdullah) + * Support passing extra context variables via the `{% component %}` tag (Matt Westcott) + * Allow subclasses of `PagesAPIViewSet` override default Page model via the `model` attribute (Neeraj Yetheendran, Herbert Poul) + * Allow `ModelViewSet` to be used with models that have non-integer primary keys (Sage Abdullah) + * Add the ability to set an external link/text for promoted search result entries (TopDevPros, Brad Busenius) + * Add support for subject and body in the Email link chooser form (TopDevPros, Alexandre Joly) + * Extract generic `HistoryView` from snippets and add it to `ModelViewSet` (Sage Abdullah) + * Add generic `UsageView` to `ModelViewSet` (Sage Abdullah) + * Add the ability to define listing buttons on generic `IndexView` (Sage Abdullah) + * Add a visual progress bar to the output of the `wagtail_update_image_renditions` management command (Faishal Manzar) + * Increase the read buffer size to improve efficiency and performance when generating file hashes for document or image uploads, use `hashlib.file_digest` if available (Python 3.11+) (Jake Howard) + * API ordering now supports multiple fields (Rohit Sharma, Jake Howard) + * Pass block value to `Block.get_template` to allow varying template based on value (Florian Delizy) + * Add `InlinePanel` DOM events for when ready and when items added or removed (Faishal Manzar) + * Add a new `picture` template tag for Django Templates and Jinja (Thibaud Colas) + * Add a new `srcset_image` template tag for Django Templates and Jinja (Thibaud Colas) + * Support `Filter` instances as input for `AbstractImage.get_renditions()` (Thibaud Colas) + * Improve error messages for image template tags (Thibaud Colas) + * Do not render minimap if there are no panel anchors (Sage Abdullah) + * Use dropdown buttons on listings in dashboard panels (Sage Abdullah) + * Implement breadcrumbs design refinements (Thibaud Colas) + * Support extending Wagtail client-side with Stimulus (LB (Ben) Johnston) + * Update all `FieldPanel('title')` examples to use the recommended `TitleFieldPanel('title')` panel (Chinedu Ihedioha) + * The `purge_revisions` management command now respects revisions that have a `on_delete=PROTECT` foreign key relation and won't delete them (Neeraj P Yetheendran, Meghana Reddy, Sage Abdullah, Storm Heg) + * Add support for Shift + Click behavior in form submissions and simple translations submissions (LB (Ben) Johnston) + * Improve filtering of audit logging based on the user's permissions (Stefan Hammer) + * Fix: Ensure that StreamField's `FieldBlock`s correctly set the `required` and `aria-describedby` attributes (Storm Heg) + * Fix: Avoid an error when the moderation panel (admin dashboard) contains both snippets and private pages (Matt Westcott) + * Fix: When deleting collections, ensure the collection name is correctly shown in the success message (LB (Ben) Johnston) + * Fix: Filter out comments on Page editing counts that do not correspond to a valid field / block path on the page such as when a field has been removed (Matt Westcott) + * Fix: Allow `PublishMenuItem` to more easily support overriding its label via `construct_page_action_menu` (Sébastien Corbin) + * Fix: Allow locale selection when creating a page at the root level (Sage Abdullah) + * Fix: Ensure the admin login template correctly displays all `non_fields_errors` for any custom form validation (Sébastien Corbin) + * Fix: Ensure 'mark as active' label in workflow bulk action set active form can be translated (Rohit Sharma) + * Fix: Ensure the panel title for a user's settings correctly reflects the `WAGTAIL_EMAIL_MANAGEMENT_ENABLED` setting by not showing 'email' if disabled (Omkar Jadhav) + * Fix: Update Spotify oEmbed provider URL parsing to resolve correctly (Dhrűv) + * Fix: Update link colors within help blocks to meet accessible contrast requirements (Rohit Sharma) + * Fix: Ensure the search promotions popular search terms picker correctly refers to the correct model (LB (Ben) Johnston) + * Fix: Correctly quote non-numeric primary keys on snippet inspect view (Sage Abdullah) + * Fix: Prevent crash on snippet inspect view when displaying a null foreign key to an image (Sage Abdullah) + * Fix: Ensure that pages in moderation show as "Live + In Moderation" in the page explorer rather than "Live + Draft" (Sage Abdullah) + * Fix: Prevent error when updating reference index for objects with a lazy ParentalKey-related object (Chris Shaw) + * Fix: Ignore conflicts when inserting reference index entries to prevent race conditions causing uniqueness errors (Chris Shaw) + * Fix: Populate the correct return value when creating a new snippet within the snippet chooser (claudobahn) + * Fix: Reinstate missing filter by page type on page search (Matt Westcott) + * Fix: Ensure very long words can wrap when viewing saved comments (Chiemezuo Akujobi) + * Fix: Avoid forgotten password link text conflicting with the supplied aria-label (Thibaud Colas) + * Fix: Fix log message to record the correct restriction type when removing a page view restriction (Rohit Sharma, Hazh. M. Adam) + * Fix: Avoid potential race condition with new Page subscriptions on the edit view (Alex Tomkins) + * Fix: Use the correct action log when creating a redirect (Thibaud Colas) + * Fix: Ensure that all password fields consistently allow leading & trailing whitespace (Neeraj P Yetheendran) + * Docs: Expand documentation on using `ViewSet` and `ModelViewSet` (Sage Abdullah) + * Docs: Document `WAGTAILADMIN_BASE_URL` on "Integrating Wagtail into a Django project" page (Shreshth Srivastava) + * Docs: Replace incorrect screenshot for authors listing on tutorial (Shreshth Srivastava) + * Docs: Add documentation for building non-model-based choosers using the _queryish_ library (Matt Westcott) + * Docs: Fix incorrect tag library import on focal points example (Hatim Makki Hoho) + * Docs: Add reminder about including your custom Draftail feature in any overridden `WAGTAILADMIN_RICH_TEXT_EDITORS` setting (Charlie Sue) + * Docs: Mention the need to install `python3-venv` on Ubuntu (Brian Mugo) + * Docs: Document the use of the Google developer documentation style guide in documentation (Damilola Oladele) + * Docs: Fix Inconsistent URL Format in Getting Started tutorial (Olumide Micheal) + * Maintenance: Fix snippet search test to work on non-fallback database backends (Matt Westcott) + * Maintenance: Update Eslint, Prettier, Jest, a11y-dialog, axe-core and js-cookie npm packages (LB (Ben) Johnston) + * Maintenance: Add npm scripts for TypeScript checks and formatting SCSS files (LB (Ben) Johnston) + * Maintenance: Run tests in parallel in some of the CI setup (Sage Abdullah) + * Maintenance: Remove unused WorkflowStatus view, urlpattern, and workflow-status.js (Storm Heg) + * Maintenance: Add support for options/attrs in Telepath widgets so that attrs render on the created DOM (Storm Heg) + * Maintenance: Update pre-commit hooks to be in sync with latest changes to Eslint & Prettier for client-side changes (Storm Heg) + * Maintenance: Add `WagtailTestUtils.get_soup()` method for testing HTML content (Storm Heg, Sage Abdullah) + * Maintenance: Allow `ViewSet` subclasses to customize `url_prefix` and `url_namespace` logic (Matt Westcott) + * Maintenance: Simplify `SnippetViewSet` registration code (Sage Abdullah) + * Maintenance: Rename groups `IndexView.results_template_name` to `results.html` (Sage Abdullah) + * Maintenance: Migrate form submission listing checkbox toggling to the shared `w-bulk` Stimulus implementation (LB (Ben) Johnston) + * Maintenance: Allow viewsets to define a common set of view kwargs (Matt Westcott) + * Maintenance: Migrate the editor unsaved messages popup to be driven by Stimulus using the shared `w-message` controller (LB (Ben) Johnston, Hussain Saherwala) + * Maintenance: Do not use jest inside `stubs.js` to prevent Storybook from crashing (LB (Ben) Johnston) + * Maintenance: Refactor snippets templates to reuse the shared `slim_header.html` template (Sage Abdullah) + * Maintenance: Refactor `slim_header.html` template to reduce code duplication (Sage Abdullah) + * Maintenance: Upgrade Willow to v1.6.2 to support MIME type data without reliance on `imghdr` (Jake Howard) + * Maintenance: Replace `imghdr` with Willow's built-in MIME type detection (Jake Howard) + * Maintenance: Migrate all other `data-tippy` HTML attribute usage to the Stimulus data-*-value attributes for w-tooltip & w-dropdown (Subhajit Ghosh, LB (Ben) Johnston) + * Maintenance: Replace `@total_ordering` usage with comparison functions implementation (Virag Jain) + * Maintenance: Replace ` +
+ MAIN +
@@ -37,16 +34,13 @@ exports[`TooltipEntity #openTooltip 1`] = ` - +
+ MAIN +
@@ -68,13 +62,13 @@ exports[`TooltipEntity #openTooltip 1`] = ` www.example.com
`; const onAdd = jest.fn(); @@ -42,7 +46,6 @@ describe('ExpandingFormset', () => { expect(onInit).not.toHaveBeenCalled(); // initialise expanding formset - // eslint-disable-next-line no-new new ExpandingFormset(prefix, { onInit, onAdd }); // check that init calls only were made for existing items @@ -51,6 +54,9 @@ describe('ExpandingFormset', () => { expect(onInit).toHaveBeenNthCalledWith(1, 0); // zero indexed expect(onInit).toHaveBeenNthCalledWith(2, 1); + // confirm inner script is not run + expect(handleLoadedEvent).not.toHaveBeenCalled(); + // click the 'add' button document .getElementById(`${prefix}-ADD`) @@ -64,6 +70,8 @@ describe('ExpandingFormset', () => { expect(document.querySelectorAll('[data-inline-panel-child]')).toHaveLength( 3, ); + // confirm inner script has been added to DOM correctly + expect(handleLoadedEvent).toHaveBeenCalledTimes(1); // check template was created into a new form item or malformed expect( @@ -101,13 +109,13 @@ describe('ExpandingFormset', () => { - +
`; const onAdd = jest.fn(); @@ -121,7 +129,6 @@ describe('ExpandingFormset', () => { expect(onInit).not.toHaveBeenCalled(); // initialise expanding formset - // eslint-disable-next-line no-new new ExpandingFormset(prefix, { onInit, onAdd }); // check that init calls only were made for existing items @@ -151,7 +158,7 @@ describe('ExpandingFormset', () => { const nestedPrefix = 'events'; const nestedTemplate = ` - + `; const onAdd = jest.fn(); @@ -206,7 +213,6 @@ describe('ExpandingFormset', () => { expect(onInit).not.toHaveBeenCalled(); // initialise expanding formset - // eslint-disable-next-line no-new new ExpandingFormset(prefix, { onInit, onAdd }); // check that init calls only were made for existing items @@ -233,11 +239,14 @@ describe('ExpandingFormset', () => { const newTemplate = document.getElementById( `${prefix}-2-events-EMPTY_FORM_TEMPLATE`, ); - expect(newTemplate).toBeTruthy(); - expect(newTemplate.textContent).toContain( + expect(newTemplate instanceof HTMLTemplateElement).toBeTruthy(); + + const newTemplateHtml = newTemplate.innerHTML; + + expect(newTemplateHtml).toContain( 'id="id_venues-2-events-__prefix__-DELETE-button"', ); - expect(newTemplate.textContent).toContain( + expect(newTemplateHtml).toContain( '', ); }); diff --git a/client/src/components/Icon/Icon.tsx b/client/src/components/Icon/Icon.tsx index bc0d83c58f9b..bf9e87f8ddfa 100644 --- a/client/src/components/Icon/Icon.tsx +++ b/client/src/components/Icon/Icon.tsx @@ -18,7 +18,7 @@ const Icon: React.FunctionComponent = ({ - {title && {title}} + {title && {title}} ); diff --git a/client/src/components/Icon/__snapshots__/Icon.test.js.snap b/client/src/components/Icon/__snapshots__/Icon.test.js.snap index aa2452579132..979619580404 100644 --- a/client/src/components/Icon/__snapshots__/Icon.test.js.snap +++ b/client/src/components/Icon/__snapshots__/Icon.test.js.snap @@ -37,7 +37,7 @@ exports[`Icon #title 1`] = ` /> Test title diff --git a/client/src/components/InlinePanel/__snapshots__/index.test.js.snap b/client/src/components/InlinePanel/__snapshots__/index.test.js.snap deleted file mode 100644 index f6d90f45cea5..000000000000 --- a/client/src/components/InlinePanel/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,18 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InlinePanel should allow inserting a new form and calling an onAdd function 1`] = ` -" -
- - - - -
-

form for inline child

-
- - -
" -`; diff --git a/client/src/components/InlinePanel/index.js b/client/src/components/InlinePanel/index.js index 4418b541e794..c450929286e6 100644 --- a/client/src/components/InlinePanel/index.js +++ b/client/src/components/InlinePanel/index.js @@ -28,6 +28,16 @@ export class InlinePanel extends ExpandingFormset { } this.updateControlStates(); + // dispatch event for form ready + setTimeout(() => { + this.formsElt.get(0)?.dispatchEvent( + new CustomEvent('w-formset:ready', { + bubbles: true, + cancelable: false, + detail: { ...opts }, + }), + ); + }); } updateControlStates() { @@ -42,14 +52,27 @@ export class InlinePanel extends ExpandingFormset { const childId = 'inline_child_' + prefix; const deleteInputId = 'id_' + prefix + '-DELETE'; const currentChild = $('#' + childId); - const $up = currentChild.find('[data-inline-panel-child-move-up]'); - const $down = currentChild.find('[data-inline-panel-child-move-down]'); + const $up = currentChild.find('[data-inline-panel-child-move-up]:first '); + const $down = currentChild.find( + '[data-inline-panel-child-move-down]:first ', + ); $('#' + deleteInputId + '-button').on('click', () => { /* set 'deleted' form field to true */ - $('#' + deleteInputId).val('1'); + $('#' + deleteInputId) + .val('1') + .get(0) + .dispatchEvent(new Event('change', { bubbles: true })); currentChild.addClass('deleted').slideUp(() => { this.updateControlStates(); + // dispatch event for deleting form + currentChild.get(0).dispatchEvent( + new CustomEvent('w-formset:removed', { + bubbles: true, + cancelable: false, + detail: { ...this.opts }, + }), + ); }); }); @@ -127,8 +150,14 @@ export class InlinePanel extends ExpandingFormset { forms.each(function updateButtonStates(i) { const isFirst = i === 0; const isLast = i === forms.length - 1; - $('[data-inline-panel-child-move-up]', this).prop('disabled', isFirst); - $('[data-inline-panel-child-move-down]', this).prop('disabled', isLast); + $('[data-inline-panel-child-move-up]:first', this).prop( + 'disabled', + isFirst, + ); + $('[data-inline-panel-child-move-down]:first', this).prop( + 'disabled', + isLast, + ); }); } } @@ -209,6 +238,29 @@ export class InlinePanel extends ExpandingFormset { ); } + /** + * Add tabindex -1 into newly created form if attr not present and + * remove attr from old forms on blur event, if not present previously. + * Always scroll and then focus on the element. + */ + initialFocus($node) { + if (!$node || !$node.length) return; + + // If element does not already have tabindex, set it + // then ensure we remove after blur (when it loses focus). + if (!$node.attr('tabindex')) { + $node.attr('tabindex', -1); + $node.one('blur', () => { + if ($node.attr('tabindex') === '-1') { + $node.removeAttr('tabindex'); + } + }); + } + + $node[0].scrollIntoView({ behavior: 'smooth' }); + $node.focus(); + } + addForm(opts = {}) { /* Supported opts: @@ -229,7 +281,10 @@ export class InlinePanel extends ExpandingFormset { this.initChildControls(newChildPrefix); if (this.opts.canOrder) { /* ORDER values are 1-based, so need to add 1 to formIndex */ - $('#id_' + newChildPrefix + '-ORDER').val(formIndex + 1); + $('#id_' + newChildPrefix + '-ORDER') + .val(formIndex + 1) + .get(0) + .dispatchEvent(new Event('change', { bubbles: true })); } this.updateControlStates(); @@ -243,5 +298,19 @@ export class InlinePanel extends ExpandingFormset { if (this.opts.onAdd) this.opts.onAdd(formIndex); if (this.opts.onInit) this.opts.onInit(formIndex); } + + this.initialFocus($(`#inline_child_${newChildPrefix}-panel-content`)); + + const newChild = this.formsElt.children().last().get(0); + if (!newChild) return; + + // dispatch event for initialising a form + newChild.dispatchEvent( + new CustomEvent('w-formset:added', { + bubbles: true, + cancelable: false, + detail: { formIndex, ...this.opts }, + }), + ); } } diff --git a/client/src/components/InlinePanel/index.test.js b/client/src/components/InlinePanel/index.test.js index b29f3ffcc2c8..e0c8b719a092 100644 --- a/client/src/components/InlinePanel/index.test.js +++ b/client/src/components/InlinePanel/index.test.js @@ -1,40 +1,102 @@ +import $ from 'jquery'; + +import { InlinePanel } from './index'; + +jest.useFakeTimers(); + describe('InlinePanel', () => { - let InlinePanel; + const handleAddedEvent = jest.fn(); + const handleRemovedEvent = jest.fn(); + const handleReadyEvent = jest.fn(); + + const onAdd = jest.fn(); beforeAll(() => { - InlinePanel = require('./index').InlinePanel; + $.fx.off = true; + jest.resetAllMocks(); document.body.innerHTML = ` -
+
- + -
`; + `; + + document.addEventListener('w-formset:added', handleAddedEvent); + document.addEventListener('w-formset:ready', handleReadyEvent); + document.addEventListener('w-formset:removed', handleRemovedEvent); }); - const onAdd = jest.fn(); + it('tests inline panel `w-formset:ready` event', () => { + expect(handleReadyEvent).not.toHaveBeenCalled(); - it('should allow inserting a new form and calling an onAdd function', () => { const options = { - formsetPrefix: 'id_person_cafe_relationship', emptyChildFormPrefix: 'person_cafe_relationship-__prefix__', - onAdd: onAdd, + formsetPrefix: 'id_person_cafe_relationship', + onAdd, }; - // eslint-disable-next-line no-new new InlinePanel(options); + jest.runAllTimers(); + + expect(handleReadyEvent).toHaveBeenCalled(); + }); + + it('should allow inserting a new form and also dispatches `w-formset:added` event on calling onAdd function', () => { + expect(handleAddedEvent).not.toHaveBeenCalled(); expect(onAdd).not.toHaveBeenCalled(); + expect(document.querySelectorAll('[data-child-form-mock]')).toHaveLength(0); // click the 'add' button document.getElementById('id_person_cafe_relationship-ADD').click(); + + expect(document.querySelectorAll('[data-child-form-mock]')).toHaveLength(1); expect(onAdd).toHaveBeenCalled(); - expect(document.body.innerHTML).toMatchSnapshot(); + + document.getElementById('id_person_cafe_relationship-ADD').click(); + expect(onAdd).toHaveBeenCalledTimes(2); + expect(document.querySelectorAll('[data-child-form-mock]')).toHaveLength(2); + + // check events were dispatched + expect(handleAddedEvent).toHaveBeenCalledTimes(2); + const [event] = handleAddedEvent.mock.calls[0]; + + expect(event.bubbles).toEqual(true); + expect(event.detail).toMatchObject({ + formIndex: 0, + formsetPrefix: 'id_person_cafe_relationship', + emptyChildFormPrefix: 'person_cafe_relationship-__prefix__', + }); + }); + + it('should allow removing a form', async () => { + expect(handleRemovedEvent).not.toHaveBeenCalled(); + expect(document.querySelectorAll('[data-child-form-mock]')).toHaveLength(2); + expect( + document.querySelectorAll('.deleted[data-child-form-mock]'), + ).toHaveLength(0); + + // click the 'delete' button + document + .getElementById('id_person_cafe_relationship-0-DELETE-button') + .click(); + + expect(document.querySelectorAll('[data-child-form-mock]')).toHaveLength(2); + expect( + document.querySelectorAll('.deleted[data-child-form-mock]'), + ).toHaveLength(1); + + expect(handleRemovedEvent).toHaveBeenCalledTimes(1); }); }); diff --git a/client/src/components/Button/Button.test.js b/client/src/components/Link/Link.test.js similarity index 67% rename from client/src/components/Button/Button.test.js rename to client/src/components/Link/Link.test.js index b9f993f4a53e..6c7c3a5f7638 100644 --- a/client/src/components/Button/Button.test.js +++ b/client/src/components/Link/Link.test.js @@ -1,40 +1,37 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ import React from 'react'; import { shallow } from 'enzyme'; -import Button from './Button'; +import Link from './Link'; -describe('Button', () => { +describe('Link', () => { it('exists', () => { - expect(Button).toBeDefined(); + expect(Link).toBeDefined(); }); it('basic', () => { - expect(shallow()).toMatchSnapshot(); + expect(shallow(To infinity and beyond!)).toMatchSnapshot(); }); it('#accessibleLabel', () => { expect( - shallow( + {!isSiteRoot && page.meta.locale && page.translations && diff --git a/client/src/components/PageExplorer/PageExplorerItem.scss b/client/src/components/PageExplorer/PageExplorerItem.scss index 87ba71e0db1c..8191b274c1c0 100644 --- a/client/src/components/PageExplorer/PageExplorerItem.scss +++ b/client/src/components/PageExplorer/PageExplorerItem.scss @@ -1,5 +1,5 @@ .c-page-explorer__item { - @apply w-flex w-flex-row w-flex-nowrap w-border-0 w-border-b w-border-solid w-border-surface-menu-item-active w-divide-x w-divide-solid w-divide-surface-menu-item-active w-divide-y-0; + @apply w-flex w-flex-row w-flex-nowrap w-border-0 w-border-b w-border-solid w-border-surface-menus w-divide-x w-divide-solid w-divide-surface-menus w-divide-y-0; } .c-page-explorer__item__link { @@ -35,10 +35,6 @@ line-height: 1; font-size: 2em; cursor: pointer; - - .icon::before { - margin-inline-end: 0; - } } .c-page-explorer__item__action--small { diff --git a/client/src/components/PageExplorer/PageExplorerItem.tsx b/client/src/components/PageExplorer/PageExplorerItem.tsx index 992f3911d668..4c2b09654a8a 100644 --- a/client/src/components/PageExplorer/PageExplorerItem.tsx +++ b/client/src/components/PageExplorer/PageExplorerItem.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { gettext } from '../../utils/gettext'; -import { ADMIN_URLS, LOCALE_NAMES } from '../../config/wagtailConfig'; +import { LOCALE_NAMES, WAGTAIL_CONFIG } from '../../config/wagtailConfig'; import Icon from '../Icon/Icon'; -import Button from '../Button/Button'; +import Link from '../Link/Link'; import PublicationStatus from '../PublicationStatus/PublicationStatus'; import { PageState } from './reducers/nodes'; +const { ADMIN_URLS } = WAGTAIL_CONFIG; + // Hoist icons in the explorer item, as it is re-rendered many times. const childrenIcon = ; @@ -35,7 +37,7 @@ const PageExplorerItem: React.FunctionComponent = ({ return (
- - + {hasChildren ? ( - + ) : null}
); diff --git a/client/src/components/PageExplorer/PageExplorerPanel.tsx b/client/src/components/PageExplorer/PageExplorerPanel.tsx index 7a420d38cf15..44ba09c72681 100644 --- a/client/src/components/PageExplorer/PageExplorerPanel.tsx +++ b/client/src/components/PageExplorer/PageExplorerPanel.tsx @@ -131,7 +131,7 @@ class PageExplorerPanel extends React.Component< >
-
+
-
- +
`; @@ -28,7 +28,7 @@ exports[`PageExplorerHeader #page 1`] = `
-
- +
`; @@ -52,7 +52,7 @@ exports[`PageExplorerHeader basic 1`] = `
-
- + `; diff --git a/client/src/components/PageExplorer/__snapshots__/PageExplorerItem.test.js.snap b/client/src/components/PageExplorer/__snapshots__/PageExplorerItem.test.js.snap index 5969ed5df1b2..9a4af71d5c4e 100644 --- a/client/src/components/PageExplorer/__snapshots__/PageExplorerItem.test.js.snap +++ b/client/src/components/PageExplorer/__snapshots__/PageExplorerItem.test.js.snap @@ -4,7 +4,7 @@ exports[`PageExplorerItem children 1`] = `
- - - +
`; @@ -46,7 +46,7 @@ exports[`PageExplorerItem renders 1`] = `
- - +
`; @@ -73,7 +73,7 @@ exports[`PageExplorerItem should show a publication status if not live 1`] = `
- - - +
`; @@ -128,7 +128,7 @@ exports[`PageExplorerItem should show a publication status with unpublished chan
- - - +
`; diff --git a/client/src/components/PageExplorer/__snapshots__/PageExplorerPanel.test.js.snap b/client/src/components/PageExplorer/__snapshots__/PageExplorerPanel.test.js.snap index 4b0d50287353..864f6c5e1150 100644 --- a/client/src/components/PageExplorer/__snapshots__/PageExplorerPanel.test.js.snap +++ b/client/src/components/PageExplorer/__snapshots__/PageExplorerPanel.test.js.snap @@ -5,7 +5,7 @@ exports[`PageExplorerPanel general rendering #isError 1`] = ` _createFocusTrap={[Function]} active={true} focusTrapOptions={ - Object { + { "allowOutsideClick": true, "clickOutsideDeactivates": false, "onDeactivate": [MockFunction], @@ -24,7 +24,7 @@ exports[`PageExplorerPanel general rendering #isError 1`] = ` name="push" >
@@ -273,7 +273,7 @@ exports[`PageExplorerPanel general rendering renders 1`] = ` _createFocusTrap={[Function]} active={true} focusTrapOptions={ - Object { + { "allowOutsideClick": true, "clickOutsideDeactivates": false, "onDeactivate": [MockFunction], @@ -292,7 +292,7 @@ exports[`PageExplorerPanel general rendering renders 1`] = ` name="push" >
( - + {status.status} ); diff --git a/client/src/components/PublicationStatus/__snapshots__/PublicationStatus.test.js.snap b/client/src/components/PublicationStatus/__snapshots__/PublicationStatus.test.js.snap index fca673e4c122..99cb1bc7ac0b 100644 --- a/client/src/components/PublicationStatus/__snapshots__/PublicationStatus.test.js.snap +++ b/client/src/components/PublicationStatus/__snapshots__/PublicationStatus.test.js.snap @@ -2,7 +2,7 @@ exports[`PublicationStatus #status live 1`] = ` live + draft @@ -10,7 +10,7 @@ exports[`PublicationStatus #status live 1`] = ` exports[`PublicationStatus #status not live 1`] = ` live + draft diff --git a/client/src/components/Sidebar/Sidebar.scss b/client/src/components/Sidebar/Sidebar.scss index c84815e3fede..d9f039ecfd40 100644 --- a/client/src/components/Sidebar/Sidebar.scss +++ b/client/src/components/Sidebar/Sidebar.scss @@ -1,3 +1,6 @@ +$sidebar-toggle-spacing: 12px; +$sidebar-toggle-size: 35px; + @mixin sidebar-toggle() { @include transition(background-color $menu-transition-duration ease); diff --git a/client/src/components/Sidebar/Sidebar.stories.tsx b/client/src/components/Sidebar/Sidebar.stories.tsx index 1e3d01dda75d..98018b29a569 100644 --- a/client/src/components/Sidebar/Sidebar.stories.tsx +++ b/client/src/components/Sidebar/Sidebar.stories.tsx @@ -8,6 +8,7 @@ import { LinkMenuItemDefinition } from './menu/LinkMenuItem'; import { SubMenuItemDefinition } from './menu/SubMenuItem'; import { WagtailBrandingModuleDefinition } from './modules/WagtailBranding'; import { range } from '../../utils/range'; +import { MenuItemDefinition } from './menu/MenuItem'; export default { title: 'Sidebar/Sidebar', @@ -27,7 +28,7 @@ function bogStandardMenuModule(): MainMenuModuleDefinition { label: 'Pages', url: '/admin/pages', icon_name: 'folder-open-inverse', - classnames: '', + classname: '', }, 1, ), @@ -36,35 +37,35 @@ function bogStandardMenuModule(): MainMenuModuleDefinition { label: 'Images', url: '/admin/images/', icon_name: 'image', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'documents', label: 'Documents', url: '/admin/documents/', icon_name: 'doc-full-inverse', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'snippets', label: 'Snippets', url: '/admin/snippets/', icon_name: 'snippet', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'forms', label: 'Forms', url: '/admin/forms/', icon_name: 'form', - classnames: '', + classname: '', }), new SubMenuItemDefinition( { name: 'reports', label: 'Reports', icon_name: 'site', - classnames: '', + classname: '', }, [ new LinkMenuItemDefinition({ @@ -72,28 +73,28 @@ function bogStandardMenuModule(): MainMenuModuleDefinition { label: 'Locked pages', url: '/admin/reports/locked/', icon_name: 'lock', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'workflows', label: 'Workflows', url: '/admin/reports/workflow/', icon_name: 'tasks', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'workflow-tasks', label: 'Workflow tasks', url: '/admin/reports/workflow_tasks/', icon_name: 'thumbtack', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'site-history', label: 'Site history', url: '/admin/reports/site-history/', icon_name: 'history', - classnames: '', + classname: '', }), ], ), @@ -102,7 +103,7 @@ function bogStandardMenuModule(): MainMenuModuleDefinition { name: 'settings', label: 'Settings', icon_name: 'cogs', - classnames: '', + classname: '', footer_text: 'Wagtail Version', }, [ @@ -111,49 +112,49 @@ function bogStandardMenuModule(): MainMenuModuleDefinition { label: 'Workflows', url: '/admin/workflows/list/', icon_name: 'tasks', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'workflow-tasks', label: 'Workflow tasks', url: '/admin/workflows/tasks/index/', icon_name: 'thumbtack', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'users', label: 'Users', url: '/admin/users/', icon_name: 'user', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'groups', label: 'Groups', url: '/admin/groups/', icon_name: 'group', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'sites', label: 'Sites', url: '/admin/sites/', icon_name: 'site', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'collections', label: 'Collections', url: '/admin/collections/', icon_name: 'folder-open-1', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'redirects', label: 'Redirects', url: '/admin/redirects/', icon_name: 'redirect', - classnames: '', + classname: '', }), ], ), @@ -164,14 +165,14 @@ function bogStandardMenuModule(): MainMenuModuleDefinition { label: 'Account', url: '/admin/account/', icon_name: 'user', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'logout', label: 'Log out', url: '/admin/logout/', icon_name: 'logout', - classnames: '', + classname: '', }), ], { @@ -204,9 +205,6 @@ function renderSidebarStory( return Promise.resolve(); }; - // Add ready class to body to enable CSS transitions - document.body.classList.add('ready'); - const onExpandCollapse = (collapsed: boolean) => { if (collapsed) { document.body.classList.add('sidebar-collapsed'); @@ -221,6 +219,13 @@ function renderSidebarStory( document.documentElement.setAttribute('dir', 'ltr'); } + React.useEffect( + () => () => { + document.documentElement.removeAttribute('dir'); + }, + [], + ); + return (
{ menuItems.push( new LinkMenuItemDefinition({ @@ -331,7 +336,7 @@ export function withLargeSubmenu() { label: `Item ${i}`, url: `/admin/item-${i}/`, icon_name: 'snippet', - classnames: '', + classname: '', }), ); }); @@ -342,7 +347,7 @@ export function withLargeSubmenu() { name: 'large-menu', label: 'Large menu', icon_name: 'cogs', - classnames: '', + classname: '', footer_text: 'Footer text', }, menuItems, @@ -359,48 +364,50 @@ export function withoutSearch() { function arabicMenuModule(): MainMenuModuleDefinition { return new MainMenuModuleDefinition( [ - new PageExplorerMenuItemDefinition({ - name: 'explorer', - label: 'صفحات', - url: '/admin/pages', - start_page_id: 1, - icon_name: 'folder-open-inverse', - classnames: '', - }), + new PageExplorerMenuItemDefinition( + { + name: 'explorer', + label: 'صفحات', + url: '/admin/pages', + icon_name: 'folder-open-inverse', + classname: '', + }, + 1, + ), new LinkMenuItemDefinition({ name: 'images', label: 'صور', url: '/admin/images/', icon_name: 'image', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'documents', label: 'وثائق', url: '/admin/documents/', icon_name: 'doc-full-inverse', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'snippets', label: 'قصاصات', url: '/admin/snippets/', icon_name: 'snippet', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'forms', label: 'نماذج', url: '/admin/forms/', icon_name: 'form', - classnames: '', + classname: '', }), new SubMenuItemDefinition( { name: 'reports', label: 'التقارير', icon_name: 'site', - classnames: '', + classname: '', }, [ new LinkMenuItemDefinition({ @@ -408,28 +415,28 @@ function arabicMenuModule(): MainMenuModuleDefinition { label: 'Locked pages', url: '/admin/reports/locked/', icon_name: 'lock', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'workflows', label: 'Workflows', url: '/admin/reports/workflow/', icon_name: 'tasks', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'workflow-tasks', label: 'Workflow tasks', url: '/admin/reports/workflow_tasks/', icon_name: 'thumbtack', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'site-history', label: 'Site history', url: '/admin/reports/site-history/', icon_name: 'history', - classnames: '', + classname: '', }), ], ), @@ -438,7 +445,7 @@ function arabicMenuModule(): MainMenuModuleDefinition { name: 'settings', label: 'إعدادات', icon_name: 'cogs', - classnames: '', + classname: '', }, [ new LinkMenuItemDefinition({ @@ -446,49 +453,49 @@ function arabicMenuModule(): MainMenuModuleDefinition { label: 'Workflows', url: '/admin/workflows/list/', icon_name: 'tasks', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'workflow-tasks', label: 'Workflow tasks', url: '/admin/workflows/tasks/index/', icon_name: 'thumbtack', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'users', label: 'مستخدمين', url: '/admin/users/', icon_name: 'user', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'groups', label: 'مجموعات', url: '/admin/groups/', icon_name: 'group', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'sites', label: 'مواقع', url: '/admin/sites/', icon_name: 'site', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'collections', label: 'مجموعات', url: '/admin/collections/', icon_name: 'folder-open-1', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'redirects', label: 'اعادة التوجيهات', url: '/admin/redirects/', icon_name: 'redirect', - classnames: '', + classname: '', }), ], ), @@ -499,14 +506,14 @@ function arabicMenuModule(): MainMenuModuleDefinition { label: 'حساب', url: '/admin/account/', icon_name: 'user', - classnames: '', + classname: '', }), new LinkMenuItemDefinition({ name: 'logout', label: 'تسجيل الخروج', url: '/admin/logout/', icon_name: 'logout', - classnames: '', + classname: '', }), ], { diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index 6d11e0828d38..de5b51bff4dd 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -87,7 +87,7 @@ export const Sidebar: React.FunctionComponent = ({ // Whether or not to display the menu with slim layout. const slim = collapsed && !isMobile; - // 'expandingOrCollapsing' is set to true whilst the the menu is transitioning between slim and expanded layouts + // 'expandingOrCollapsing' is set to true whilst the menu is transitioning between slim and expanded layouts const [expandingOrCollapsing, setExpandingOrCollapsing] = React.useState(false); diff --git a/client/src/components/Sidebar/SidebarPanel.scss b/client/src/components/Sidebar/SidebarPanel.scss index c5e97ca9a5dc..7db944d1c0d4 100644 --- a/client/src/components/Sidebar/SidebarPanel.scss +++ b/client/src/components/Sidebar/SidebarPanel.scss @@ -1,7 +1,7 @@ .sidebar-panel { - @apply w-transition-sidebar; // With CSS variable allows panels with different widths to animate properly --width: #{$menu-width}; + @apply w-transition-sidebar; visibility: hidden; transform: translateX(calc(var(--w-direction-factor) * -100%)); diff --git a/client/src/components/Sidebar/index.tsx b/client/src/components/Sidebar/index.tsx index 8eab663406e9..96601060273a 100644 --- a/client/src/components/Sidebar/index.tsx +++ b/client/src/components/Sidebar/index.tsx @@ -51,7 +51,6 @@ export function initSidebar() { />, element, () => { - document.body.classList.add('ready'); document .querySelector('[data-wagtail-sidebar]') ?.classList.remove('sidebar-loading'); diff --git a/client/src/components/Sidebar/menu/ActionMenuItem.tsx b/client/src/components/Sidebar/menu/ActionMenuItem.tsx index 061b74891b5c..0daf52533cef 100644 --- a/client/src/components/Sidebar/menu/ActionMenuItem.tsx +++ b/client/src/components/Sidebar/menu/ActionMenuItem.tsx @@ -81,9 +81,9 @@ export class ActionMenuItemDefinition implements MenuItemDefinition { name, label, action, - attrs, + attrs = {}, icon_name: iconName = null, - classnames = undefined, + classname = undefined, method = 'POST', }) { this.name = name; @@ -91,7 +91,7 @@ export class ActionMenuItemDefinition implements MenuItemDefinition { this.action = action; this.attrs = attrs; this.iconName = iconName; - this.classNames = classnames; + this.classNames = classname; this.method = method; } diff --git a/client/src/components/Sidebar/menu/LinkMenuItem.tsx b/client/src/components/Sidebar/menu/LinkMenuItem.tsx index c891dc098f4b..1fc00bc1f020 100644 --- a/client/src/components/Sidebar/menu/LinkMenuItem.tsx +++ b/client/src/components/Sidebar/menu/LinkMenuItem.tsx @@ -96,16 +96,16 @@ export class LinkMenuItemDefinition implements MenuItemDefinition { name, label, url, - attrs, - icon_name: iconName = null, - classnames = undefined, + attrs = {}, + icon_name: iconName = null as string | null, + classname = undefined as string | undefined, }) { this.name = name; this.label = label; this.url = url; this.attrs = attrs; this.iconName = iconName; - this.classNames = classnames; + this.classNames = classname; } render({ path, slim, state, dispatch, navigate }) { diff --git a/client/src/components/Sidebar/menu/MenuItem.scss b/client/src/components/Sidebar/menu/MenuItem.scss index 5c72c028be3d..9d2cd27c97b7 100644 --- a/client/src/components/Sidebar/menu/MenuItem.scss +++ b/client/src/components/Sidebar/menu/MenuItem.scss @@ -35,25 +35,6 @@ color: theme('colors.text-label-menus-active'); text-shadow: -1px -1px 0 theme('colors.black-35'); } - - &:before { - width: 1rem; - height: 1rem; - font-size: 1rem; - display: flex; - align-items: center; - // Ensure consistent button height in collapsed state where no text line-height is adding 1.5px. - margin: 0.046875rem 0; - } - - // only really used for spinners and settings menu - &:after { - font-size: 1.5em; - margin: 0; - position: absolute; - inset-inline-end: 0.5em; - top: 0.5em; - } } &--in-sub-menu { diff --git a/client/src/components/Sidebar/menu/PageExplorerMenuItem.tsx b/client/src/components/Sidebar/menu/PageExplorerMenuItem.tsx index 92c3cf79bbbe..ebc7ac831041 100644 --- a/client/src/components/Sidebar/menu/PageExplorerMenuItem.tsx +++ b/client/src/components/Sidebar/menu/PageExplorerMenuItem.tsx @@ -120,13 +120,13 @@ export class PageExplorerMenuItemDefinition extends LinkMenuItemDefinition { name, label, url, - attrs, - icon_name: iconName = null, - classnames = undefined, + attrs = {}, + icon_name: iconName = null as string | null, + classname = undefined as string | undefined, }, startPageId: number, ) { - super({ name, label, url, attrs, icon_name: iconName, classnames }); + super({ name, label, url, attrs, icon_name: iconName, classname }); this.startPageId = startPageId; } diff --git a/client/src/components/Sidebar/menu/SubMenuItem.scss b/client/src/components/Sidebar/menu/SubMenuItem.scss index 9edd17bb67a9..530914ef7a29 100644 --- a/client/src/components/Sidebar/menu/SubMenuItem.scss +++ b/client/src/components/Sidebar/menu/SubMenuItem.scss @@ -1,16 +1,16 @@ .sidebar-sub-menu-trigger-icon { $root: &; - display: block; - width: 1rem; - height: 1rem; - inset-inline-end: 15px; - margin-inline-start: auto; @include transition( transform $menu-transition-duration ease, width $menu-transition-duration ease, height $menu-transition-duration ease ); + display: block; + width: 1rem; + height: 1rem; + inset-inline-end: 15px; + margin-inline-start: auto; &--open { transform-origin: 50% 50%; @@ -37,21 +37,11 @@ > h2 { // w-min-h-[160px] and w-mt-[35px] classes are to vertically align the title and icon combination to the search input on the left @apply w-min-h-[180px] w-px-4 w-box-border w-text-center w-text-text-label-menus-default w-mb-0 w-inline-flex w-flex-col w-justify-center w-items-center w-transition-sidebar; - - &:before { - font-size: 4em; - display: block; - text-align: center; - margin: 0 0 0.2em; - width: 100%; - opacity: 0.15; - } } ul > li { - position: relative; - @include transition(border-color $menu-transition-duration ease); + position: relative; } > ul { diff --git a/client/src/components/Sidebar/menu/SubMenuItem.tsx b/client/src/components/Sidebar/menu/SubMenuItem.tsx index 73b893f366fe..808a4202ab62 100644 --- a/client/src/components/Sidebar/menu/SubMenuItem.tsx +++ b/client/src/components/Sidebar/menu/SubMenuItem.tsx @@ -168,9 +168,9 @@ export class SubMenuItemDefinition implements MenuItemDefinition { { name, label, - attrs, + attrs = {}, icon_name: iconName = null, - classnames = undefined, + classname = undefined, footer_text: footerText = '', }: any, menuItems: MenuItemDefinition[], @@ -180,7 +180,7 @@ export class SubMenuItemDefinition implements MenuItemDefinition { this.menuItems = menuItems; this.attrs = attrs; this.iconName = iconName; - this.classNames = classnames; + this.classNames = classname; this.footerText = footerText; } diff --git a/client/src/components/Sidebar/menu/__snapshots__/PageExplorererMenuItem.test.js.snap b/client/src/components/Sidebar/menu/__snapshots__/PageExplorererMenuItem.test.js.snap index 3a9cc6802d54..362597ce3095 100644 --- a/client/src/components/Sidebar/menu/__snapshots__/PageExplorererMenuItem.test.js.snap +++ b/client/src/components/Sidebar/menu/__snapshots__/PageExplorererMenuItem.test.js.snap @@ -37,7 +37,7 @@ exports[`PageExplorerMenuItem should render with the minimum required props 1`] > ul > li > a { // Need !important to override body.ready class + // stylelint-disable-next-line declaration-no-important transition: padding $menu-transition-duration ease !important; + } - .menuitem-label { - transition: opacity $menu-transition-duration ease; - } + .menuitem-label { + transition: opacity $menu-transition-duration ease; } } .sidebar-footer { @apply w-bg-surface-menus w-mt-auto; + // stylelint-disable-next-line declaration-no-important transition: width $menu-transition-duration ease !important; // Override body.ready > ul, @@ -57,12 +58,6 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - - &:before { - font-size: 1rem; - margin-inline-end: 0.5em; - vertical-align: -10%; - } } } diff --git a/client/src/components/Sidebar/modules/MainMenu.tsx b/client/src/components/Sidebar/modules/MainMenu.tsx index 10c4205614dd..216ad46658af 100644 --- a/client/src/components/Sidebar/modules/MainMenu.tsx +++ b/client/src/components/Sidebar/modules/MainMenu.tsx @@ -165,7 +165,7 @@ export const Menu: React.FunctionComponent = ({ // They are created by concatenating the name fields of all the menu/sub-menu items leading to the relevant one. // For example, the "Users" item in the "Settings" sub-menu would have the path 'settings.users' // - navigationPath references the current sub-menu that the user currently has open - // - activePath references the menu item for the the page the user is currently on + // - activePath references the menu item for the page the user is currently on const [state, dispatch] = React.useReducer(menuReducer, { navigationPath: '', activePath: '', @@ -310,9 +310,7 @@ export const Menu: React.FunctionComponent = ({ hover:w-bg-surface-menu-item-active focus:w-bg-surface-menu-item-active w-transition`} - title={gettext('Edit your account')} onClick={onClickAccountSettings} - aria-label={gettext('Edit your account')} aria-haspopup="menu" aria-expanded={accountSettingsOpen ? 'true' : 'false'} type="button" diff --git a/client/src/components/Sidebar/modules/WagtailBranding.scss b/client/src/components/Sidebar/modules/WagtailBranding.scss index 091f5bf6bb59..e12f0bc9f8af 100644 --- a/client/src/components/Sidebar/modules/WagtailBranding.scss +++ b/client/src/components/Sidebar/modules/WagtailBranding.scss @@ -1,4 +1,5 @@ -// stylelint-disable declaration-no-important +/* stylelint-disable declaration-no-important, selector-attribute-name-disallowed-list */ + $logo-size: 110px; // Wagging animation @@ -8,7 +9,7 @@ $logo-size: 110px; } to { - transform: rotate(7deg); + transform: rotate(20deg) translate(30%, -25%) scale(1.1); } } @@ -22,8 +23,10 @@ $logo-size: 110px; text-align: center; width: $logo-size; height: $logo-size; - transition: transform 150ms cubic-bezier(0.28, 0.15, 0, 2.1), - width $menu-transition-duration ease, height $menu-transition-duration ease, + transition: + transform 150ms cubic-bezier(0.28, 0.15, 0, 2.1), + width $menu-transition-duration ease, + height $menu-transition-duration ease, padding-top $menu-transition-duration ease; border-radius: 100%; @@ -53,7 +56,6 @@ $logo-size: 110px; animation-iteration-count: infinite; } - // TODO: Fix legacy specificity issues [data-part='eye--open'] { display: none !important; } diff --git a/client/src/components/Sidebar/modules/WagtailLogo.tsx b/client/src/components/Sidebar/modules/WagtailLogo.tsx index 4b6646e7610c..6236caae46fc 100644 --- a/client/src/components/Sidebar/modules/WagtailLogo.tsx +++ b/client/src/components/Sidebar/modules/WagtailLogo.tsx @@ -28,14 +28,14 @@ const WagtailLogo = ({ className, slim }: WagtailLogoProps) => { ${className || ''} ${ slim - ? 'w-w-[58px] w-h-[57px] w-top-2 hover:-w-translate-y-1' + ? 'w-w-[58px] w-h-[57px] w-top-2 hover:w-translate-x-1 hover:-w-translate-y-1' : 'w-w-[120px] w-h-[200px] -w-top-1 hover:w-translate-x-2 hover:-w-translate-y-3' } `} - width="430" - height="537" - viewBox="0 0 430 537" - enableBackground="new 0 0 430 537" + width="225" + height="274" + viewBox="0 0 225 274" + enableBackground="new 0 0 225 274" xmlSpace="preserve" aria-hidden="true" > @@ -43,64 +43,58 @@ const WagtailLogo = ({ className, slim }: WagtailLogoProps) => { - + ); diff --git a/client/src/components/Sidebar/modules/__snapshots__/MainMenu.test.js.snap b/client/src/components/Sidebar/modules/__snapshots__/MainMenu.test.js.snap index a967a805be12..63080252b194 100644 --- a/client/src/components/Sidebar/modules/__snapshots__/MainMenu.test.js.snap +++ b/client/src/components/Sidebar/modules/__snapshots__/MainMenu.test.js.snap @@ -20,7 +20,6 @@ exports[`Menu should render with the minimum required props 1`] = `
+

drink more water

+
+

The widget

+
" `; diff --git a/client/src/components/StreamField/blocks/__snapshots__/ListBlock.test.js.snap b/client/src/components/StreamField/blocks/__snapshots__/ListBlock.test.js.snap index 8ce9b1f73a23..e3c926b62c20 100644 --- a/client/src/components/StreamField/blocks/__snapshots__/ListBlock.test.js.snap +++ b/client/src/components/StreamField/blocks/__snapshots__/ListBlock.test.js.snap @@ -1,1229 +1,1229 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`telepath: wagtail.blocks.ListBlock blocks can be duplicated 1`] = ` -"
- " `; exports[`telepath: wagtail.blocks.ListBlock blocks can be reordered downward 1`] = ` -"
- " `; exports[`telepath: wagtail.blocks.ListBlock blocks can be reordered upward 1`] = ` -"
- " `; exports[`telepath: wagtail.blocks.ListBlock blocks can be split 1`] = ` -"
- " `; exports[`telepath: wagtail.blocks.ListBlock deleteBlock() deletes a block 1`] = ` -"
- " `; exports[`telepath: wagtail.blocks.ListBlock it renders correctly 1`] = ` -"
- " `; exports[`telepath: wagtail.blocks.ListBlock setError passes error messages to children 1`] = ` -"
- " `; exports[`telepath: wagtail.blocks.ListBlock setError renders non-block errors 1`] = ` -"
- " `; diff --git a/client/src/components/StreamField/blocks/__snapshots__/StaticBlock.test.js.snap b/client/src/components/StreamField/blocks/__snapshots__/StaticBlock.test.js.snap index 70020ba08016..9625b61225ce 100644 --- a/client/src/components/StreamField/blocks/__snapshots__/StaticBlock.test.js.snap +++ b/client/src/components/StreamField/blocks/__snapshots__/StaticBlock.test.js.snap @@ -3,7 +3,7 @@ exports[`telepath: wagtail.blocks.StaticBlock HTML escaping boundblock matches the snapshot 1`] = ` StaticBlock { "blockDef": StaticBlockDefinition { - "meta": Object { + "meta": { "icon": "icon", "label": "The label", "text": "The admin text ", @@ -18,7 +18,7 @@ exports[`telepath: wagtail.blocks.StaticBlock HTML escaping it renders correctly exports[`telepath: wagtail.blocks.StaticBlock allows safe HTML boundblock matches the snapshot 1`] = ` StaticBlock { "blockDef": StaticBlockDefinition { - "meta": Object { + "meta": { "html": "The admin text ", "icon": "icon", "label": "The label", @@ -33,7 +33,7 @@ exports[`telepath: wagtail.blocks.StaticBlock allows safe HTML it renders correc exports[`telepath: wagtail.blocks.StaticBlock boundblock matches the snapshot 1`] = ` StaticBlock { "blockDef": StaticBlockDefinition { - "meta": Object { + "meta": { "icon": "icon", "label": "The label", "text": "The admin text", diff --git a/client/src/components/StreamField/blocks/__snapshots__/StreamBlock.test.js.snap b/client/src/components/StreamField/blocks/__snapshots__/StreamBlock.test.js.snap index 0f28c146127e..15f1029f0076 100644 --- a/client/src/components/StreamField/blocks/__snapshots__/StreamBlock.test.js.snap +++ b/client/src/components/StreamField/blocks/__snapshots__/StreamBlock.test.js.snap @@ -1,925 +1,1019 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`telepath: wagtail.blocks.StreamBlock blocks can be duplicated 1`] = ` -"
-
+"
+
use plenty of these
-
- -
-
" `; exports[`telepath: wagtail.blocks.StreamBlock blocks can be reordered downward 1`] = ` -"
-
+"
+
use plenty of these
-
- -
-
" `; exports[`telepath: wagtail.blocks.StreamBlock blocks can be reordered upward 1`] = ` -"
-
+"
+
use plenty of these
-
- -
-
" `; exports[`telepath: wagtail.blocks.StreamBlock it renders correctly 1`] = ` -"
-
+"
+
use plenty of these
-
- -
-
" `; exports[`telepath: wagtail.blocks.StreamBlock it renders menus on opening 1`] = ` -"
-
+"
+
use plenty of these
-
- -
-
" `; exports[`telepath: wagtail.blocks.StreamBlock setError renders error messages 1`] = ` -"
-
+"
+
use plenty of these
-

At least three blocks are required

- -
-
" `; -exports[`telepath: wagtail.blocks.StreamBlock with labels that need escaping it renders correctly 1`] = `"
Test Block <A>
Test Block <B>
"`; +exports[`telepath: wagtail.blocks.StreamBlock with labels that need escaping it renders correctly 1`] = `"
Test Block <A>
Test Block <B>
"`; + +exports[`telepath: wagtail.blocks.StreamBlock with unique block type it can add block 1`] = ` +"
+
+ use plenty of this +
+
+ +
+ +
+ + + + +
+
+ + + + +

+ + Test Block A + * +

+ + + +
+
+
+
+
+
+
+ +
+
+
+

Block A widget

+
+
+
+
+
+
+ +
+
" +`; + +exports[`telepath: wagtail.blocks.StreamBlock with unique block type it renders correctly without combobox 1`] = ` +"
+
+ use plenty of this +
+
+ +
+ +
+
" +`; diff --git a/client/src/components/StreamField/blocks/__snapshots__/StructBlock.test.js.snap b/client/src/components/StreamField/blocks/__snapshots__/StructBlock.test.js.snap index 28e9797f23ad..a9483d9e3727 100644 --- a/client/src/components/StreamField/blocks/__snapshots__/StructBlock.test.js.snap +++ b/client/src/components/StreamField/blocks/__snapshots__/StructBlock.test.js.snap @@ -1,36 +1,36 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`telepath: wagtail.blocks.StructBlock it renders correctly 1`] = ` -"
+"
-
-
+
+
use lots of these
-
- -
-
-
- +
+ +
+
+
+
-
-
-

Heading widget

+
+
+

Heading widget

-
- -
-
-
- +
+ +
+
+
+
-
-
-

Size widget

+
+
+

Size widget

@@ -38,36 +38,36 @@ exports[`telepath: wagtail.blocks.StructBlock it renders correctly 1`] = ` `; exports[`telepath: wagtail.blocks.StructBlock setError passes error messages to children 1`] = ` -"
+"
-
-
+
+
use lots of these
-
- -
-
-
- +
+ +
+
+
+
-
-
-

Heading widget

+
+
+

Heading widget

-
- -
-
-
- -

This is too big

-
-
-

Size widget

+
+ +
+
+
+ +

This is too big.

+
+
+

Size widget

@@ -75,36 +75,36 @@ exports[`telepath: wagtail.blocks.StructBlock setError passes error messages to `; exports[`telepath: wagtail.blocks.StructBlock setError shows non-block errors 1`] = ` -"

This is just generally wrong

+"

This is just generally wrong.

-
-
+
+
use lots of these
-
- -
-
-
- +
+ +
+
+
+
-
-
-

Heading widget

+
+
+

Heading widget

-
- -
-
-
- +
+ +
+
+
+
-
-
-

Size widget

+
+
+

Size widget

@@ -112,28 +112,28 @@ exports[`telepath: wagtail.blocks.StructBlock setError shows non-block errors 1` `; exports[`telepath: wagtail.blocks.StructBlock with formTemplate it renders correctly 1`] = ` -"
+"

here comes the first field:

-
-
-
- +
+
+
+
-
-
-

Heading widget

+
+
+

Heading widget

and here is the second:

-
-
-
- -
-
-
-

Size widget

+
+
+
+ +
+
+
+

Size widget

diff --git a/client/src/components/StreamField/scss/components/c-sf-add-button.scss b/client/src/components/StreamField/scss/components/c-sf-add-button.scss index eb2eed617376..b54f4af0fa00 100644 --- a/client/src/components/StreamField/scss/components/c-sf-add-button.scss +++ b/client/src/components/StreamField/scss/components/c-sf-add-button.scss @@ -31,7 +31,7 @@ $icon-size: theme('spacing.4'); &:focus-visible { .icon { color: theme('colors.surface-page'); - background-color: theme('colors.text-button-outline-default'); + background-color: theme('colors.text-button-outline-hover'); } } diff --git a/client/src/components/Transition/Transition.scss b/client/src/components/Transition/Transition.scss index 64b2ededa780..7f334dd5d51c 100644 --- a/client/src/components/Transition/Transition.scss +++ b/client/src/components/Transition/Transition.scss @@ -2,34 +2,36 @@ // Transitions // ============================================================================= -$c-transition-duration: 200ms; +$w-transition-duration: 200ms; -.c-transition-group { +.w-transition-group { position: absolute; width: 100%; top: 0; } -.c-transition-push-enter { +.w-transition-push-enter { transform: translateX(calc(var(--w-direction-factor) * 100%)); - transition: transform $c-transition-duration ease, - opacity $c-transition-duration linear; + transition: + transform $w-transition-duration ease, + opacity $w-transition-duration linear; opacity: 0; } -.c-transition-push-enter-active { +.w-transition-push-enter-active { transform: translateX(0); opacity: 1; } -.c-transition-push-leave { +.w-transition-push-leave { transform: translateX(0); - transition: transform $c-transition-duration ease, - opacity $c-transition-duration linear; + transition: + transform $w-transition-duration ease, + opacity $w-transition-duration linear; opacity: 1; } -.c-transition-push-leave-active { +.w-transition-push-leave-active { transform: translateX(calc(var(--w-direction-factor) * -100%)); opacity: 0; } @@ -37,26 +39,28 @@ $c-transition-duration: 200ms; // ============================================================================= // Pop transition // ============================================================================= -.c-transition-pop-enter { +.w-transition-pop-enter { transform: translateX(calc(var(--w-direction-factor) * -100%)); - transition: transform $c-transition-duration ease, - opacity $c-transition-duration linear; + transition: + transform $w-transition-duration ease, + opacity $w-transition-duration linear; opacity: 0; } -.c-transition-pop-enter-active { +.w-transition-pop-enter-active { transform: translateX(0); opacity: 1; } -.c-transition-pop-leave { +.w-transition-pop-leave { transform: translateX(0); - transition: transform $c-transition-duration ease, - opacity $c-transition-duration linear; + transition: + transform $w-transition-duration ease, + opacity $w-transition-duration linear; opacity: 1; } -.c-transition-pop-leave-active { +.w-transition-pop-leave-active { transform: translateX(calc(var(--w-direction-factor) * 100%)); opacity: 0; } diff --git a/client/src/components/Transition/Transition.tsx b/client/src/components/Transition/Transition.tsx index 747b5eafec94..32d482d7ba43 100644 --- a/client/src/components/Transition/Transition.tsx +++ b/client/src/components/Transition/Transition.tsx @@ -17,7 +17,7 @@ const Transition = ({ name, component, className, duration, children }) => ( component={component} transitionEnterTimeout={duration} transitionLeaveTimeout={duration} - transitionName={`c-transition-${name}`} + transitionName={`w-transition-${name}`} className={className} > {children} diff --git a/client/src/components/Transition/__snapshots__/Transition.test.js.snap b/client/src/components/Transition/__snapshots__/Transition.test.js.snap index 7afd9695f4b8..1dbe15a37f9e 100644 --- a/client/src/components/Transition/__snapshots__/Transition.test.js.snap +++ b/client/src/components/Transition/__snapshots__/Transition.test.js.snap @@ -9,7 +9,7 @@ exports[`Transition basic 1`] = ` transitionEnterTimeout={210} transitionLeave={true} transitionLeaveTimeout={210} - transitionName="c-transition-push" + transitionName="w-transition-push" /> `; @@ -22,6 +22,6 @@ exports[`Transition label 1`] = ` transitionEnterTimeout={210} transitionLeave={true} transitionLeaveTimeout={210} - transitionName="c-transition-push" + transitionName="w-transition-push" /> `; diff --git a/client/src/config/wagtailConfig.js b/client/src/config/wagtailConfig.js deleted file mode 100644 index db7454d1c690..000000000000 --- a/client/src/config/wagtailConfig.js +++ /dev/null @@ -1,33 +0,0 @@ -export const { ADMIN_API } = global.wagtailConfig; -export const { ADMIN_URLS } = global.wagtailConfig; - -// Maximum number of pages to load inside the explorer menu. -export const MAX_EXPLORER_PAGES = 200; - -export const LOCALE_NAMES = new Map(); - -/* eslint-disable-next-line camelcase */ -global.wagtailConfig.LOCALES.forEach(({ code, display_name }) => { - LOCALE_NAMES.set(code, display_name); -}); - -function getWagtailConfig() { - // TODO: Move window.wagtailConfig from the base HTML template - // to the wagtail-config JSON script. - - try { - return JSON.parse(document.getElementById('wagtail-config')?.textContent); - } catch (err) { - /* eslint-disable no-console */ - console.error('Error loading Wagtail config'); - console.error(err); - /* eslint-enable no-console */ - - // This shouldn't happen as the config is generated with json_script tag - // from the server, but if for some reason the element does not contain - // valid JSON, ignore it and return an empty object. - return {}; - } -} - -export const WAGTAIL_CONFIG = getWagtailConfig(); diff --git a/client/src/config/wagtailConfig.test.js b/client/src/config/wagtailConfig.test.js index dc50ce942a7c..b18878e8fb24 100644 --- a/client/src/config/wagtailConfig.test.js +++ b/client/src/config/wagtailConfig.test.js @@ -1,32 +1,31 @@ import { - ADMIN_API, - ADMIN_URLS, + LOCALE_NAMES, MAX_EXPLORER_PAGES, WAGTAIL_CONFIG, } from './wagtailConfig'; describe('wagtailConfig', () => { - describe('ADMIN_API', () => { + describe('LOCALE_NAMES', () => { it('exists', () => { - expect(ADMIN_API).toBeDefined(); - }); - }); - - describe('ADMIN_URLS', () => { - it('exists', () => { - expect(ADMIN_URLS).toBeDefined(); + expect(LOCALE_NAMES).toBeInstanceOf(Map); + expect(LOCALE_NAMES.get('fr')).toEqual('French'); }); }); describe('MAX_EXPLORER_PAGES', () => { it('exists', () => { - expect(MAX_EXPLORER_PAGES).toBeDefined(); + expect(MAX_EXPLORER_PAGES).toBeGreaterThan(0); }); }); describe('WAGTAIL_CONFIG', () => { it('exists', () => { - expect(WAGTAIL_CONFIG).toBeDefined(); + expect(WAGTAIL_CONFIG).toEqual( + expect.objectContaining({ + ACTIVE_LOCALE: expect.any(String), + LOCALES: expect.any(Array), + }), + ); }); }); }); diff --git a/client/src/config/wagtailConfig.ts b/client/src/config/wagtailConfig.ts new file mode 100644 index 000000000000..fdf810d576e8 --- /dev/null +++ b/client/src/config/wagtailConfig.ts @@ -0,0 +1,39 @@ +import type { WagtailConfig } from '../custom.d'; + +const getWagtailConfig = ( + config = (global as any).wagtailConfig as WagtailConfig, +) => { + // Avoid re-parsing the JSON if global has been already created in core.js + if (config) return config; + try { + const json = document.getElementById('wagtail-config')?.textContent || ''; + return JSON.parse(json); + } catch (err) { + /* eslint-disable no-console */ + console.error('Error loading Wagtail config'); + console.error(err); + /* eslint-enable no-console */ + + // This shouldn't happen as the config is generated with json_script tag + // from the server, but if for some reason the element does not contain + // valid JSON, ignore it and return an empty object. + return {}; + } +}; + +const config = getWagtailConfig() as WagtailConfig; + +/** + * Maximum number of pages to load inside the explorer menu. + */ +export const MAX_EXPLORER_PAGES = 200; + +export const LOCALE_NAMES = (config.LOCALES || []).reduce( + (locales, { code, display_name: displayName }) => { + locales.set(code, displayName); + return locales; + }, + new Map(), +); + +export { config as WAGTAIL_CONFIG }; diff --git a/client/src/controllers/ActionController.stories.js b/client/src/controllers/ActionController.stories.js index 1da2625d2044..a6d0264335e6 100644 --- a/client/src/controllers/ActionController.stories.js +++ b/client/src/controllers/ActionController.stories.js @@ -4,7 +4,7 @@ import { StimulusWrapper } from '../../storybook/StimulusWrapper'; import { ActionController } from './ActionController'; export default { - title: 'Shared / ActionController', + title: 'Stimulus / ActionController', argTypes: { debug: { control: 'boolean', diff --git a/client/src/controllers/ActionController.test.js b/client/src/controllers/ActionController.test.js index 356b18cfd33a..30f5fa3ad934 100644 --- a/client/src/controllers/ActionController.test.js +++ b/client/src/controllers/ActionController.test.js @@ -1,10 +1,21 @@ import { Application } from '@hotwired/stimulus'; import { ActionController } from './ActionController'; +import { UnsavedController } from './UnsavedController'; describe('ActionController', () => { let app; const oldWindowLocation = window.location; + const setup = async (html) => { + document.body.innerHTML = `
${html}
`; + + app = Application.start(); + app.register('w-action', ActionController); + app.register('w-unsaved', UnsavedController); + + await Promise.resolve(); + }; + beforeAll(() => { delete window.location; @@ -13,6 +24,17 @@ describe('ActionController', () => { { ...Object.getOwnPropertyDescriptors(oldWindowLocation), assign: { configurable: true, value: jest.fn() }, + reload: { + configurable: true, + value: jest.fn().mockImplementation(() => { + const event = new Event('beforeunload'); + Object.defineProperty(event, 'returnValue', { + value: null, + writable: true, + }); + window.dispatchEvent(event); + }), + }, }, ); }); @@ -23,23 +45,19 @@ describe('ActionController', () => { }); describe('post method', () => { - beforeEach(() => { - document.body.innerHTML = ` - - `; - - app = Application.start(); - app.register('w-action', ActionController); - }); - - it('it should allow for a form POST with created data', () => { + beforeEach(async () => { + await setup(` + `); + }); + + it('should allow for a form POST with created data', () => { const btn = document.querySelector('[data-controller="w-action"]'); const submitMock = jest.fn(); window.HTMLFormElement.prototype.submit = submitMock; @@ -54,9 +72,47 @@ describe('ActionController', () => { }); }); + describe('sendBeacon method', () => { + beforeEach(async () => { + await setup(` + + + `); + }); + + it('should send a POST request using sendBeacon with the CSRF token included', () => { + const sendBeaconMock = jest.fn(); + Object.defineProperty(window.navigator, 'sendBeacon', { + value: sendBeaconMock, + }); + + const btn = document.querySelector('[data-controller="w-action"]'); + const otherBtn = document.getElementById('other-button'); + btn.focus(); + otherBtn.focus(); + + expect(sendBeaconMock).toHaveBeenCalledTimes(1); + expect(sendBeaconMock).toHaveBeenCalledWith( + 'https://analytics.example/not-interested', + expect.any(FormData), + ); + + const formData = sendBeaconMock.mock.lastCall[1]; + expect( + Object.fromEntries(formData.entries()).csrfmiddlewaretoken, + ).toEqual('potato'); + }); + }); + describe('click method', () => { - beforeEach(() => { - document.body.innerHTML = ` + beforeEach(async () => { + await setup(` - `; - - app = Application.start(); - app.register('w-action', ActionController); + `); }); it('should call click method when button is clicked via Stimulus action', () => { const btn = document.getElementById('button'); - const clickMock = jest.fn(); - HTMLButtonElement.prototype.click = clickMock; - - btn.addEventListener('some-event', btn.click()); + const clickMock = jest.spyOn(HTMLButtonElement.prototype, 'click'); const event = new CustomEvent('some-event'); btn.dispatchEvent(event); @@ -85,17 +134,132 @@ describe('ActionController', () => { }); }); + describe('reload method', () => { + beforeEach(async () => { + await setup(` + `); + }); + + it('should reload the page', () => { + const beforeUnloadHandler = jest.fn(); + window.addEventListener('beforeunload', beforeUnloadHandler); + + document.getElementById('button').click(); + + expect(window.location.reload).toHaveBeenCalledTimes(1); + expect(beforeUnloadHandler).toHaveBeenCalledTimes(1); + + const event = beforeUnloadHandler.mock.lastCall[0]; + // These mean the browser confirmation dialog was not shown + expect(event.defaultPrevented).toBe(false); + expect(event.returnValue).toBeNull(); + + window.removeEventListener('beforeunload', beforeUnloadHandler); + }); + + it('should not bypass the browser confirmation dialog if the event is prevented', async () => { + document.body.innerHTML = /* html */ ` +
+
+ + `; + await Promise.resolve(); + + // Simulate having unsaved changes by setting has-edits-value to true. + // We can't set this on init because the value is set to false on connect. + document + .querySelector('form') + .setAttribute('data-w-unsaved-has-edits-value', 'true'); + await Promise.resolve(); + const beforeUnloadHandler = jest.fn(); + window.addEventListener('beforeunload', beforeUnloadHandler); + + document.getElementById('button').click(); + + expect(window.location.reload).toHaveBeenCalledTimes(1); + expect(beforeUnloadHandler).toHaveBeenCalledTimes(1); + + const event = beforeUnloadHandler.mock.lastCall[0]; + // This means the browser confirmation dialog was shown + expect(event.returnValue).toBe('You have unsaved changes!'); + window.removeEventListener('beforeunload', beforeUnloadHandler); + }); + }); + + describe('forceReload method', () => { + beforeEach(async () => { + await setup(/* html */ ` +
+
+ `); + + // Simulate having unsaved changes by setting has-edits-value to true. + // We can't set this on init because the value is set to false on connect. + document + .querySelector('form') + .setAttribute('data-w-unsaved-has-edits-value', 'true'); + await Promise.resolve(); + }); + + it('should reload the page without showing the browser confirmation dialog', () => { + const confirmHandler = jest.fn(); + const beforeUnloadHandler = jest.fn(); + document.addEventListener('w-unsaved:confirm', confirmHandler); + window.addEventListener('beforeunload', beforeUnloadHandler); + + document.getElementById('button').click(); + + expect(window.location.reload).toHaveBeenCalledTimes(1); + expect(beforeUnloadHandler).toHaveBeenCalledTimes(1); + + const beforeUnloadEvent = beforeUnloadHandler.mock.lastCall[0]; + // If the browser confirmation was shown, these would be truthy + expect(beforeUnloadEvent.defaultPrevented).toBe(false); + expect(beforeUnloadEvent.returnValue).toBeNull(); + + expect(confirmHandler).toHaveBeenCalledTimes(1); + const confirmEvent = confirmHandler.mock.lastCall[0]; + // We're preventing UnsavedController from triggering the browser confirmation + expect(confirmEvent.defaultPrevented).toBe(true); + + window.removeEventListener('beforeunload', beforeUnloadHandler); + document.removeEventListener('w-unsaved:confirm', confirmHandler); + }); + }); + describe('redirect method', () => { - beforeEach(() => { - document.body.innerHTML = ` + beforeEach(async () => { + await setup(` - `; - - app = Application.start(); - app.register('w-action', ActionController); + `); }); it('should have a redirect method that falls back to any element value', () => { @@ -172,4 +336,177 @@ describe('ActionController', () => { expect(window.location.assign).not.toHaveBeenCalled(); }); }); + + describe('select method', () => { + it('select should be called when you click on text in textarea', async () => { + await setup(` + + `); + + const textarea = document.getElementById('text'); + + // check that there is no selection initially + expect(textarea.selectionStart).toBe(0); + expect(textarea.selectionEnd).toBe(0); + + // focus + textarea.focus(); + + // check that there is a selection after focus + expect(textarea.selectionStart).toBe(0); + expect(textarea.selectionEnd).toBe(textarea.value.length); + }); + + it('select should be called for for input elements', async () => { + await setup(` + + `); + + const input = document.getElementById('input'); + + // check that there is no selection initially + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(0); + + const event = new CustomEvent('some-event'); + input.dispatchEvent(event); + + // check that there is a selection after the event + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(16); + }); + + it('select should not throw errors when called on a button element', async () => { + await setup(` + + `); + + const button = document.getElementById('button'); + + expect(() => button.click()).not.toThrow(); + }); + }); + + describe('reset method', () => { + const handleChangeEvent = jest.fn(); + document.addEventListener('change', handleChangeEvent); + + beforeEach(async () => { + jest.resetAllMocks(); + + await setup( + ``, + ); + }); + + it('should change value when existing value and new value are different', () => { + const input = document.getElementById('reset-test'); + + // Change the value to something else (via JS) + input.value = 'another input value'; + expect(handleChangeEvent).not.toHaveBeenCalled(); + + input.dispatchEvent( + new CustomEvent('some-event', { detail: { value: 'not the default' } }), + ); + + expect(input.value).toBe('not the default'); + expect(input.value).not.toBe('another input value'); + expect(handleChangeEvent).toHaveBeenCalled(); + }); + + it('should not change value when current value and new value are the same', () => { + expect(handleChangeEvent).not.toHaveBeenCalled(); + const input = document.getElementById('reset-test'); + + input.dispatchEvent( + new CustomEvent('some-event', { detail: { value: 'the default' } }), + ); + + expect(input.value).toBe('the default'); + expect(input.value).not.toBe('not the default'); + expect(handleChangeEvent).not.toHaveBeenCalled(); + }); + + it('should reset value to a new value supplied via custom event detail', () => { + expect(handleChangeEvent).not.toHaveBeenCalled(); + const input = document.getElementById('reset-test'); + + input.dispatchEvent( + new CustomEvent('some-event', { + detail: { value: 'a new value from custom event detail' }, + }), + ); + + expect(input.value).toBe('a new value from custom event detail'); + expect(input.value).not.toBe('the default'); + expect(handleChangeEvent).toHaveBeenCalled(); + }); + + it('should reset value to a new value supplied in action param', () => { + expect(handleChangeEvent).not.toHaveBeenCalled(); + const input = document.getElementById('reset-test'); + input.setAttribute( + 'data-w-action-value-param', + 'a new value from action params', + ); + + input.dispatchEvent(new CustomEvent('some-event')); + + expect(input.value).toBe('a new value from action params'); + expect(handleChangeEvent).toHaveBeenCalled(); + }); + }); + + describe('noop method', () => { + beforeEach(async () => { + await setup(` + `); + }); + + it('should a noop method that does nothing, enabling use of action options', async () => { + const button = document.getElementById('button'); + + const onClick = jest.fn(); + document.addEventListener('click', onClick); + + button.dispatchEvent(new Event('click', { bubbles: true })); + + expect(onClick).not.toHaveBeenCalled(); + + // remove data-action attribute + await Promise.resolve(button.removeAttribute('data-action')); + + button.dispatchEvent(new Event('click', { bubbles: true })); + + expect(onClick).toHaveBeenCalled(); + }); + }); }); diff --git a/client/src/controllers/ActionController.ts b/client/src/controllers/ActionController.ts index 567607a7ea37..6e5cf002f471 100644 --- a/client/src/controllers/ActionController.ts +++ b/client/src/controllers/ActionController.ts @@ -26,6 +26,11 @@ import { WAGTAIL_CONFIG } from '../config/wagtailConfig'; * Enable * * + * @example - triggering a POST request via sendBeacon + * + * * @example - triggering a dynamic redirect * // note: a link is preferred normally *
@@ -34,9 +39,21 @@ import { WAGTAIL_CONFIG } from '../config/wagtailConfig'; * * *
+ * + * @example - triggering selection of the text in a field + *
+ * + *
+ * + * @example - ensuring a button's click does not propagate + *
+ * + *
*/ export class ActionController extends Controller< - HTMLButtonElement | HTMLInputElement + HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement > { static values = { continue: { type: Boolean, default: false }, @@ -50,10 +67,16 @@ export class ActionController extends Controller< this.element.click(); } - post(event: Event) { - event.preventDefault(); - event.stopPropagation(); + /** + * Intentionally does nothing. + * + * Useful for attaching data-action to leverage the built in + * Stimulus options without needing any extra functionality. + * e.g. preventDefault (`:prevent`) and stopPropagation (`:stop`). + */ + noop() {} + private createFormElement() { const formElement = document.createElement('form'); formElement.action = this.urlValue; @@ -76,10 +99,47 @@ export class ActionController extends Controller< formElement.appendChild(nextElement); } + return formElement; + } + + post(event: Event) { + event.preventDefault(); + event.stopPropagation(); + const formElement = this.createFormElement(); document.body.appendChild(formElement); formElement.submit(); } + /** + * Like post, but uses the Beacon API, which can be used to send data + * to a server without waiting for a response. Useful for sending analytics + * data or a "release" signal before navigating away from a page. + */ + sendBeacon() { + navigator.sendBeacon(this.urlValue, new FormData(this.createFormElement())); + } + + /** + * Reload the browser. + */ + reload() { + window.location.reload(); + } + + /** + * Reload the browser, bypassing the browser dialog triggered by UnsavedController. + */ + forceReload() { + window.addEventListener( + 'w-unsaved:confirm', + (event) => { + event.preventDefault(); + }, + { once: true }, + ); + window.location.reload(); + } + /** * Trigger a redirect based on the custom event's detail, the Stimulus param * or finally check the controlled element for a value to use. @@ -91,4 +151,46 @@ export class ActionController extends Controller< if (!url) return; window.location.assign(url); } + + /** + * Reset the field to a supplied or the field's initial value (default). + * Only update if the value to change to is different from the current value. + */ + reset( + event: CustomEvent<{ value?: string }> & { params?: { value?: string } }, + ) { + const target = this.element; + const currentValue = target.value; + + const { value: newValue = '' } = { + value: target instanceof HTMLInputElement ? target.defaultValue : '', + ...event?.params, + ...event?.detail, + }; + + if (currentValue === newValue) return; + + target.value = newValue; + this.dispatch('change', { + bubbles: true, + cancelable: false, + prefix: '', + target, + }); + } + + /** + * Select all the text in an input or textarea element. + */ + select() { + const element = this.element; + + if ( + element && + (element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement) + ) { + element.select(); + } + } } diff --git a/client/src/controllers/AutosizeController.test.js b/client/src/controllers/AutosizeController.test.js index e36410c846e9..11145cb7a8a6 100644 --- a/client/src/controllers/AutosizeController.test.js +++ b/client/src/controllers/AutosizeController.test.js @@ -25,8 +25,7 @@ describe('AutosizeController', () => { - `; + >`; }); afterEach(() => { diff --git a/client/src/controllers/BlockController.test.js b/client/src/controllers/BlockController.test.js new file mode 100644 index 000000000000..308fb004d3f4 --- /dev/null +++ b/client/src/controllers/BlockController.test.js @@ -0,0 +1,125 @@ +import { Application } from '@hotwired/stimulus'; +import { BlockController } from './BlockController'; + +const render = jest.fn(); +const unpack = jest.fn(() => ({ render })); +window.telepath = { unpack }; + +describe('BlockController', () => { + const eventNames = ['w-block:ready']; + + const events = {}; + + eventNames.forEach((name) => { + document.addEventListener(name, (event) => { + events[name].push(event); + }); + }); + + let application; + let errors = []; + + const setup = (html, { identifier = 'w-block' } = {}) => { + document.body.innerHTML = `
${html}
`; + + application = new Application(); + + application.register(identifier, BlockController); + + application.handleError = (error, message) => { + errors.push({ error, message }); + }; + + application.start(); + + return Promise.resolve(); + }; + + beforeEach(() => { + application?.stop(); + document.body.innerHTML = ''; + errors = []; + eventNames.forEach((name) => { + events[name] = []; + }); + jest.clearAllMocks(); + }); + + it('does nothing if block element is not found', async () => { + await setup('
'); + + expect(errors).toHaveLength(0); + + expect(unpack).not.toHaveBeenCalled(); + + expect(events['w-block:ready']).toHaveLength(0); + }); + + it('should render block if element is controlled', async () => { + const data = { _args: ['...'], _type: 'wagtail.blocks.StreamBlock' }; + + await setup( + `
+
`, + ); + + expect(errors).toHaveLength(0); + expect(unpack).toHaveBeenCalledWith(data); + expect(render).toHaveBeenCalledWith( + document.getElementById('my-element'), + 'my-element', + ); + expect(events['w-block:ready']).toHaveLength(1); + }); + + it('should call the unpacked render function with provided initial & error data', async () => { + const data = { _args: ['...'], _type: 'wagtail.blocks.StreamBlock' }; + const initialData = [{ type: 'paragraph_block', value: '...' }]; + const errorData = { messages: ['An error...'] }; + + await setup( + `
+
`, + ); + + expect(errors).toHaveLength(0); + expect(unpack).toHaveBeenCalledWith(data); + expect(render).toHaveBeenCalledWith( + document.getElementById('my-element'), + 'my-element', + initialData, + errorData, + ); + expect(events['w-block:ready']).toHaveLength(1); + }); + + it('should throw an error if used on an element without an id', async () => { + await setup('
'); + + expect(errors).toHaveLength(1); + expect(errors).toHaveProperty( + '0.error.message', + 'Controlled element needs an id attribute.', + ); + }); + + it('should throw an error if Telepath is not available in the window global', async () => { + delete window.telepath; + await setup('
'); + + expect(errors).toHaveLength(1); + expect(errors).toHaveProperty( + '0.error.message', + '`window.telepath` is not available.', + ); + }); +}); diff --git a/client/src/controllers/BlockController.ts b/client/src/controllers/BlockController.ts new file mode 100644 index 000000000000..a240acf382e5 --- /dev/null +++ b/client/src/controllers/BlockController.ts @@ -0,0 +1,86 @@ +import { Controller } from '@hotwired/stimulus'; + +declare global { + interface Window { + initBlockWidget?: (id: string) => void; + telepath: any; + } +} + +/** + * Adds the ability to unpack a Telepath object and render it on the controlled element. + * Used to initialize the top-level element of a BlockWidget (the form widget for a StreamField). + * + * @example + *
+ *
+ * + * @example - with initial arguments + *
+ *
+ */ +export class BlockController extends Controller { + static values = { + arguments: { type: Array, default: [] }, + data: { type: Object, default: {} }, + }; + + /** Array of arguments to pass to the render method of the block [initial value, errors]. */ + declare argumentsValue: Array; + /** Block definition to be passed to `telepath.unpack`, used to obtain a JavaScript representation of the block. */ + declare dataValue: object; + + connect() { + const telepath = window.telepath; + + if (!telepath) { + throw new Error('`window.telepath` is not available.'); + } + + const element = this.element; + const id = element.id; + + if (!id) { + throw new Error('Controlled element needs an id attribute.'); + } + + const output = telepath.unpack(this.dataValue); + output.render(element, id, ...this.argumentsValue); + this.dispatch('ready', { detail: { ...output }, cancelable: false }); + } + + static afterLoad() { + /** + * Provide a backwards compatible version of the original window global function. + * + * @deprecated RemovedInWagtail70 + */ + window.initBlockWidget = (id: string) => { + const body = document.querySelector( + '#' + id + '[data-block]', + ) as HTMLElement; + + if (!body) { + return; + } + + const blockDefData = JSON.parse(body.dataset.data as string); + if (window.telepath) { + const blockDef = window.telepath.unpack(blockDefData); + const blockValue = JSON.parse(body.dataset.value as string); + const blockError = JSON.parse(body.dataset.error as string); + + blockDef.render(body, id, blockValue, blockError); + } + }; + } +} diff --git a/client/src/controllers/BulkController.test.js b/client/src/controllers/BulkController.test.js index df956401c234..9a0af6da20dc 100644 --- a/client/src/controllers/BulkController.test.js +++ b/client/src/controllers/BulkController.test.js @@ -2,9 +2,29 @@ import { Application } from '@hotwired/stimulus'; import { BulkController } from './BulkController'; describe('BulkController', () => { - beforeEach(() => { - document.body.innerHTML = ` -
+ let application; + let handleError; + + const shiftClick = async (element) => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Shift', + shiftKey: true, + }), + ); + element.click(); + document.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'Shift', + shiftKey: true, + }), + ); + await Promise.resolve(); + }; + + const setup = async ( + html = ` +
@@ -13,13 +33,23 @@ describe('BulkController', () => {
-
- `; - const application = Application.start(); +
`, + ) => { + document.body.innerHTML = `
${html}
`; + + application = Application.start(); application.register('w-bulk', BulkController); + handleError = jest.fn(); + application.handleError = handleError; + }; + + afterEach(() => { + jest.clearAllMocks(); }); - it('selects all checkboxes when the select all checkbox is clicked', () => { + it('selects all checkboxes when the select all checkbox is clicked', async () => { + await setup(); + const allCheckbox = document.getElementById('select-all'); allCheckbox.click(); @@ -39,7 +69,9 @@ describe('BulkController', () => { ).toEqual(0); }); - it('should keep the select all checkbox in sync when individual checkboxes are all ticked', () => { + it('should keep the select all checkbox in sync when individual checkboxes are all ticked', async () => { + await setup(); + const allCheckbox = document.getElementById('select-all'); expect(allCheckbox.checked).toBe(false); @@ -64,7 +96,9 @@ describe('BulkController', () => { expect(allCheckbox.checked).toBe(false); }); - it('executes the correct action when the Clear all button is clicked', () => { + it('executes the correct action when the Clear all button is clicked', async () => { + await setup(); + const allCheckbox = document.getElementById('select-all'); const clearAllButton = document.getElementById('clear'); expect(allCheckbox.checked).toBe(false); @@ -89,7 +123,9 @@ describe('BulkController', () => { expect(allCheckbox.checked).toBe(false); }); - it('executes the correct action when the Set all button is clicked', () => { + it('executes the correct action when the Set all button is clicked', async () => { + await setup(); + const allCheckbox = document.getElementById('select-all'); const setAllButton = document.getElementById('set'); @@ -115,4 +151,393 @@ describe('BulkController', () => { expect(itemCheckbox.checked).toBe(true); }); }); + + it('should support using another method (e.g. CustomEvent) to toggle all', async () => { + await setup(); + + const allCheckbox = document.getElementById('select-all'); + expect(allCheckbox.checked).toBe(false); + + document.dispatchEvent(new CustomEvent('custom:event')); + + expect(allCheckbox.checked).toBe(true); + expect(document.querySelectorAll(':checked')).toHaveLength(3); + + // calling again, should switch the toggles back + + document.dispatchEvent(new CustomEvent('custom:event')); + + expect(allCheckbox.checked).toBe(false); + expect(document.querySelectorAll(':checked')).toHaveLength(0); + }); + + it('should support a force value in a CustomEvent to override the select all checkbox', async () => { + await setup(); + + const allCheckbox = document.getElementById('select-all'); + expect(allCheckbox.checked).toBe(false); + + document.dispatchEvent( + new CustomEvent('custom:event', { detail: { force: true } }), + ); + + expect(allCheckbox.checked).toBe(true); + expect(document.querySelectorAll(':checked')).toHaveLength(3); + + // calling again, should not change the state of the checkboxes + document.dispatchEvent( + new CustomEvent('custom:event', { detail: { force: true } }), + ); + + expect(allCheckbox.checked).toBe(true); + expect(document.querySelectorAll(':checked')).toHaveLength(3); + }); + + it('should allow for action targets to have classes toggled when any checkboxes are clicked', async () => { + await setup(); + + const container = document.getElementById('bulk-container'); + + // create innerActions container that will be conditionally hidden with test classes + container.setAttribute( + 'data-w-bulk-action-inactive-class', + 'hidden w-invisible', + ); + const innerActions = document.createElement('div'); + innerActions.id = 'inner-actions'; + innerActions.className = 'keep-me hidden w-invisible'; + innerActions.setAttribute('data-w-bulk-target', 'action'); + container.prepend(innerActions); + + const innerActionsElement = document.getElementById('inner-actions'); + + expect( + document + .getElementById('checkboxes') + .querySelectorAll(':checked:not(:disabled)').length, + ).toEqual(0); + + expect(innerActionsElement.className).toEqual('keep-me hidden w-invisible'); + + const firstCheckbox = document + .getElementById('checkboxes') + .querySelector("[type='checkbox']:not([disabled])"); + + firstCheckbox.click(); + + expect(innerActionsElement.className).toEqual('keep-me'); + + firstCheckbox.click(); + + expect(innerActionsElement.className).toEqual('keep-me hidden w-invisible'); + }); + + it('should support shift+click to select a range of checkboxes', async () => { + await setup(` +
+ +
+ + + + + + + +
+
`); + + const getClickedIds = () => + Array.from(document.querySelectorAll(':checked')).map(({ id }) => id); + + // initial shift usage should have no impact + await shiftClick(document.getElementById('c0')); + expect(getClickedIds()).toHaveLength(1); + + // shift click should select all checkboxes between the first and last clicked + await shiftClick(document.getElementById('c2')); + expect(getClickedIds()).toEqual(['c0', 'c1', 'c2']); + + await shiftClick(document.getElementById('c5')); + expect(getClickedIds()).toEqual([ + 'select-all-multi', + 'c0', + 'c1', + 'c2', + 'c3', + 'c4', + 'c5', + ]); + + // it should allow reverse clicks + document.getElementById('c4').click(); // un-click + expect(getClickedIds()).toEqual(['c0', 'c1', 'c2', 'c3', 'c5']); + + // now shift click in reverse, un-clicking those between the last (4) and the new click (1) + await shiftClick(document.getElementById('c1')); + expect(getClickedIds()).toEqual(['c0', 'c5']); + + // reset the clicks, then using shift click should do nothing + document.getElementById('select-all-multi').click(); + document.getElementById('select-all-multi').click(); + expect(getClickedIds()).toHaveLength(0); + + await shiftClick(document.getElementById('c4')); + expect(getClickedIds()).toEqual(['c4']); + + // finally, do a shift click to the first checkbox, check the select all works after a final click + await shiftClick(document.getElementById('c0')); + expect(getClickedIds()).toEqual(['c0', 'c1', 'c2', 'c3', 'c4']); + + document.getElementById('c5').click(); + + expect(getClickedIds()).toEqual([ + 'select-all-multi', + 'c0', + 'c1', + 'c2', + 'c3', + 'c4', + 'c5', + ]); + + // now ensure that it still works if some element gets changed (not disabled) + document.getElementById('cx').removeAttribute('disabled'); + document.getElementById('select-all-multi').click(); + expect(getClickedIds()).toHaveLength(0); + + await Promise.resolve(); + + document.getElementById('c3').click(); // click + + await shiftClick(document.getElementById('c1')); + + // it should include the previously disabled element, tracking against the DOM, not indexes + expect(getClickedIds()).toEqual(['c1', 'cx', 'c2', 'c3']); + }); + + describe('support for groups of checkboxes being used', () => { + const html = ` + + + + + + + ${[...Array(5).keys()] + .map( + (i) => ` + + + + + + `, + ) + .join('\n')} + + + + + + + +
+ Misc items + + + + +
NameAddChangeDelete
Item ${i + 1}
+ Check all (Add & Change) + + + + Check all (Add) + + + Check all (Change) + + + Check all (Delete) + +
+ `; + + it('should allow for the toggleAll method to be used to select all, irrespective of groupings', async () => { + const totalCheckboxes = 24; + + await setup(html); + + const allCheckbox = document.getElementById('select-all'); + expect(allCheckbox.checked).toBe(false); + expect(document.querySelectorAll('[type="checkbox"')).toHaveLength( + totalCheckboxes, + ); + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + allCheckbox.click(); + + expect(allCheckbox.checked).toBe(true); + expect(document.querySelectorAll(':checked')).toHaveLength( + totalCheckboxes, + ); + + allCheckbox.click(); + expect(document.querySelectorAll(':checked')).toHaveLength(0); + }); + + it('should allow for the toggleAll method to be used for single group toggling', async () => { + await setup(html); + + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + document.getElementById('select-all-delete').click(); + + const checked = document.querySelectorAll(':checked'); + + expect(checked).toHaveLength(9); + + expect(checked).toEqual( + document.querySelectorAll('[data-w-bulk-group-param~="delete"]'), + ); + + const otherCheckbox = document.getElementById('row-3-add'); + + otherCheckbox.click(); + + expect(document.querySelectorAll(':checked')).toHaveLength(10); + + document.getElementById('select-all-delete').click(); + + expect(document.querySelectorAll(':checked')).toHaveLength(1); + expect(otherCheckbox.checked).toEqual(true); + }); + + it('should allow for the toggleAll method to be used for multi group toggling', async () => { + await setup(html); + + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + document.getElementById('select-all-add-change').click(); + + const checked = document.querySelectorAll(':checked'); + expect(checked).toHaveLength(17); + expect([...checked].map(({ id }) => id)).toEqual( + expect.arrayContaining([ + 'misc-any', + 'misc-add-change', + 'misc-change-delete', + 'misc-add-change-delete', + 'row-0-add', + 'row-0-change', + // ... others not needing explicit call out + ]), + ); + + // specific group select all checkboxes should now be checked automatically + expect(document.getElementById('select-all-add').checked).toEqual(true); + expect(document.getElementById('select-all-change').checked).toEqual( + true, + ); + }); + + it('should support shift+click within the groups', async () => { + await setup(html); + + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + document.getElementById('row-0-change').click(); + + await shiftClick(document.getElementById('row-2-change')); + + // only checkboxes in + expect(document.getElementById('row-1-change').checked).toEqual(true); + expect(document.querySelectorAll(':checked')).toHaveLength(3); + + // now shift again to the last checkbox + await shiftClick(document.getElementById('row-4-change')); + expect(document.getElementById('row-3-change').checked).toEqual(true); + expect(document.querySelectorAll(':checked')).toHaveLength(5); + }); + + it('should not throw an error when shift+clicking across groups', async () => { + await setup(html); + expect(document.querySelectorAll(':checked')).toHaveLength(0); + document.getElementById('row-0-add').click(); + + // Same row, different group + await shiftClick(document.getElementById('row-0-change')); + + // Should not throw an error, and only select the two checkboxes + expect(application.handleError).not.toHaveBeenCalled(); + expect(document.getElementById('row-0-add').checked).toEqual(true); + expect(document.getElementById('row-0-change').checked).toEqual(true); + expect(document.querySelectorAll(':checked')).toHaveLength(2); + + document.getElementById('row-1-change').click(); + + // Different row, different group + await shiftClick(document.getElementById('row-3-add')); + + // Should not throw an error, and only select the two checkboxes + // in addition to the two already selected + expect(application.handleError).not.toHaveBeenCalled(); + expect(document.getElementById('row-1-change').checked).toEqual(true); + expect(document.getElementById('row-3-add').checked).toEqual(true); + expect(document.getElementById('row-0-add').checked).toEqual(true); + expect(document.getElementById('row-0-change').checked).toEqual(true); + expect(document.querySelectorAll(':checked')).toHaveLength(4); + }); + + it('should support the group being passed in via a CustomEvent', async () => { + await setup(html); + + const table = document.getElementById('table'); + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + table.dispatchEvent( + new CustomEvent('custom:event', { detail: { group: 'delete' } }), + ); + + await Promise.resolve(); + + const checked = document.querySelectorAll(':checked'); + + expect(checked).toHaveLength(9); + expect([...checked].map(({ id }) => id)).toEqual([ + 'misc-any', + 'misc-change-delete', + 'misc-add-change-delete', + 'row-0-delete', + 'row-1-delete', + 'row-2-delete', + 'row-3-delete', + 'row-4-delete', + 'select-all-delete', + ]); + + // now check one of the non-delete checkboxes + document.getElementById('row-0-add').click(); + expect(document.querySelectorAll(':checked')).toHaveLength(10); + + // use force to toggle only the delete checkboxes off + table.dispatchEvent( + new CustomEvent('custom:event', { + detail: { group: 'delete', force: false }, + }), + ); + + expect(document.querySelectorAll(':checked')).toHaveLength(1); + + // run a second time to confirm there should be no difference due to force + table.dispatchEvent( + new CustomEvent('custom:event', { + detail: { group: 'delete', force: false }, + }), + ); + + expect(document.querySelectorAll(':checked')).toHaveLength(1); + }); + }); }); diff --git a/client/src/controllers/BulkController.ts b/client/src/controllers/BulkController.ts index 2f29eab3c624..dcda11cb6b0a 100644 --- a/client/src/controllers/BulkController.ts +++ b/client/src/controllers/BulkController.ts @@ -1,21 +1,87 @@ import { Controller } from '@hotwired/stimulus'; + +type ToggleOptions = { + /** Only toggle those within the provided group(s), a space separated set of strings. */ + group?: string; +}; + +type ToggleAllOptions = ToggleOptions & { + /** Override check all behaviour to either force check or uncheck all */ + force?: boolean; +}; + /** * Adds the ability to collectively toggle a set of (non-disabled) checkboxes. * - * @example + * @example - Basic usage *
* *
- * - * - * + * + * + * *
* * *
+ * + * @example - Showing and hiding an actions container + *
+ *
+ * + *
+ * + *
+ * + * + * + *
+ *
+ * + * @example - Using groups to allow toggles to be controlled separately or together + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
NameAddChange
Item 1
Item 2
+ * Check all (Add & Change) + * + * + * Check all (Add) + * + * + * Check all (Change) + * + *
+ * */ export class BulkController extends Controller { - static targets = ['all', 'item']; + static classes = ['actionInactive']; + static targets = ['action', 'all', 'item']; + + /** Target(s) that will have the `actionInactive` classes removed if any actions are checked */ + declare readonly actionTargets: HTMLElement[]; /** All select-all checkbox targets */ declare readonly allTargets: HTMLInputElement[]; @@ -23,37 +89,154 @@ export class BulkController extends Controller { /** All item checkbox targets */ declare readonly itemTargets: HTMLInputElement[]; - get activeItems() { - return this.itemTargets.filter(({ disabled }) => !disabled); - } + /** Classes to remove on the actions target if any actions are checked */ + declare readonly actionInactiveClasses: string[]; + + /** Internal tracking of last clicked for shift+click behaviour */ + lastChanged?: HTMLElement | null; + + /** Internal tracking of whether the shift key is active for multiple selection */ + shiftActive?: boolean; /** * On creation, ensure that the select all checkboxes are in sync. + * Set up the event listeners for shift+click behaviour. */ connect() { this.toggle(); + this.handleShiftKey = this.handleShiftKey.bind(this); + document.addEventListener('keydown', this.handleShiftKey); + document.addEventListener('keyup', this.handleShiftKey); } /** - * When something is toggled, ensure the select all targets are kept in sync. + * Returns all valid targets (i.e. not disabled). */ - toggle() { - const isAllChecked = !this.activeItems.some((item) => !item.checked); - this.allTargets.forEach((target) => { + getValidTargets( + group: string | null = null, + targets: HTMLInputElement[] = this.itemTargets, + paramAttr = `data-${this.identifier}-group-param`, + ): HTMLInputElement[] { + const activeTargets = targets.filter(({ disabled }) => !disabled); + + if (!group) return activeTargets; + + const groups = group.split(' '); + return activeTargets.filter((target) => { + const targetGroups = new Set( + (target.getAttribute(paramAttr) || '').split(' '), + ); + return groups.some(targetGroups.has.bind(targetGroups)); + }); + } + + /** + * Event handler to determine if shift key is pressed. + */ + handleShiftKey(event: KeyboardEvent) { + if (!event) return; + + const { shiftKey, type } = event; + + if (type === 'keydown' && shiftKey) { + this.shiftActive = true; + } + + if (type === 'keyup' && this.shiftActive) { + this.shiftActive = false; + } + } + + /** + * When an item is toggled, ensure the select all targets are kept in sync. + * Update the classes on the action targets to reflect the current state. + * If the shift key is pressed, toggle all the items between the last clicked + * item and the current item. + */ + toggle(event?: CustomEvent & { params?: ToggleOptions }) { + const { group = null } = { ...event?.detail, ...event?.params }; + const activeItems = this.getValidTargets(group); + const lastChanged = this.lastChanged; + + if (this.shiftActive && lastChanged instanceof HTMLElement) { + this.shiftActive = false; + + const lastClickedIndex = activeItems.findIndex( + (item) => item === lastChanged, + ); + + // The last clicked item is not in the current group, skip bulk toggling + if (lastClickedIndex === -1) return; + + const currentIndex = activeItems.findIndex( + (item) => item === event?.target, + ); + + const [start, end] = [lastClickedIndex, currentIndex].sort( + // eslint-disable-next-line id-length + (a, b) => a - b, + ); + + activeItems.forEach((target, index) => { + if (index >= start && index <= end) { + // eslint-disable-next-line no-param-reassign + target.checked = !!activeItems[lastClickedIndex].checked; + this.dispatch('change', { target, bubbles: true }); + } + }); + } + + this.lastChanged = + activeItems.find((item) => item.contains(event?.target as Node)) ?? null; + + const totalCheckedItems = activeItems.filter((item) => item.checked).length; + const isAnyChecked = totalCheckedItems > 0; + const isAllChecked = totalCheckedItems === activeItems.length; + + this.getValidTargets(group, this.allTargets).forEach((target) => { // eslint-disable-next-line no-param-reassign target.checked = isAllChecked; }); + + const actionInactiveClasses = this.actionInactiveClasses; + if (!actionInactiveClasses.length) return; + + this.actionTargets.forEach((element) => { + actionInactiveClasses.forEach((actionInactiveClass) => { + element.classList.toggle(actionInactiveClass, !isAnyChecked); + }); + }); } /** - * Toggles all item checkboxes based on select-all checkbox. + * Toggles all item checkboxes, can be used to force check or uncheck all. + * If the event used to trigger this method does not have a suitable target, + * the first allTarget will be used to determine the current checked value. */ - toggleAll(event: Event & { params?: { force?: boolean } }): void { - const force = event?.params?.force; - const checkbox = event.target as HTMLInputElement; - const isChecked = typeof force === 'boolean' ? force : checkbox.checked; + toggleAll( + event: CustomEvent & { params?: ToggleAllOptions }, + ) { + const { force = null, group = null } = { + ...event.detail, + ...event.params, + }; + + this.lastChanged = null; - this.activeItems.forEach((target) => { + let isChecked = false; + + if (typeof force === 'boolean') { + isChecked = force; + } else if (event.target instanceof HTMLInputElement) { + isChecked = event.target.checked; + } else { + const checkbox = this.allTargets[0]; + // use the opposite of the current state + // as this is being triggered by an external call + isChecked = !checkbox?.checked; + } + + this.getValidTargets(group).forEach((target) => { if (target.checked !== isChecked) { // eslint-disable-next-line no-param-reassign target.checked = isChecked; @@ -61,6 +244,11 @@ export class BulkController extends Controller { } }); - this.toggle(); + this.toggle(event); + } + + disconnect() { + document?.removeEventListener('keydown', this.handleShiftKey); + document?.removeEventListener('keyup', this.handleShiftKey); } } diff --git a/client/src/controllers/ClipboardController.test.js b/client/src/controllers/ClipboardController.test.js new file mode 100644 index 000000000000..3ce04331c336 --- /dev/null +++ b/client/src/controllers/ClipboardController.test.js @@ -0,0 +1,207 @@ +import { Application } from '@hotwired/stimulus'; +import { ClipboardController } from './ClipboardController'; + +jest.useFakeTimers(); + +describe('ClipboardController', () => { + const handleEvent = jest.fn(); + + document.addEventListener('w-clipboard:copy', handleEvent); + document.addEventListener('w-clipboard:copied', handleEvent); + document.addEventListener('w-clipboard:error', handleEvent); + + let app; + const writeText = jest.fn(() => Promise.resolve()); + + const setup = async ( + html = ` +
+ + +
+ `, + ) => { + document.body.innerHTML = `
${html}
`; + + app = Application.start(); + app.register('w-clipboard', ClipboardController); + + await Promise.resolve(); + }; + + afterEach(() => { + app?.stop(); + jest.clearAllMocks(); + }); + + describe('when the clipboard is not available', () => { + it('should not have the clipboard available', () => { + expect(window.navigator.clipboard).toBeUndefined(); + }); + + it('should send an error event if the copy method is called', async () => { + await setup(); + + expect(handleEvent).not.toHaveBeenCalled(); + + document.querySelector('button').click(); + + await jest.runAllTimersAsync(); + + expect(handleEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'w-clipboard:error', + detail: { clear: true, type: 'error' }, + }), + ); + }); + }); + + describe('when the clipboard is available', () => { + beforeAll(() => { + Object.defineProperty(window.navigator, 'clipboard', { + writable: true, + value: { writeText }, + }); + }); + + it('should have the clipboard available', () => { + expect(window.navigator.clipboard).toBeTruthy(); + }); + + it('should copy the content from the value target with the copy method', async () => { + await setup(); + + expect(handleEvent).not.toHaveBeenCalled(); + expect(writeText).not.toHaveBeenCalled(); + + document.querySelector('button').click(); + + expect(handleEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'w-clipboard:copy' }), + ); + + await jest.runAllTimersAsync(); + + expect(writeText).toHaveBeenCalledWith('copy me to the clipboard'); + expect(handleEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'w-clipboard:copied', + detail: { clear: true, type: 'success' }, + }), + ); + }); + + it('should support a way to block the copy method with event listeners', async () => { + document.addEventListener( + 'w-clipboard:copy', + (event) => event.preventDefault(), + { once: true }, + ); + + await setup(); + + expect(handleEvent).not.toHaveBeenCalled(); + expect(writeText).not.toHaveBeenCalled(); + + document.querySelector('button').click(); + + expect(handleEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'w-clipboard:copy' }), + ); + + await jest.runAllTimersAsync(); + + expect(writeText).not.toHaveBeenCalled(); + expect(handleEvent).toHaveBeenCalledTimes(1); + }); + + it('should support a custom dispatched event with the value to copy', async () => { + expect(handleEvent).not.toHaveBeenCalled(); + expect(writeText).not.toHaveBeenCalled(); + + await setup(); + + document.getElementById('container').dispatchEvent( + new CustomEvent('some-event', { + detail: { value: 'copy me from the event' }, + }), + ); + + expect(handleEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'w-clipboard:copy' }), + ); + + await jest.runAllTimersAsync(); + + expect(writeText).toHaveBeenCalledWith('copy me from the event'); + expect(handleEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'w-clipboard:copied', + detail: { clear: true, type: 'success' }, + }), + ); + }); + + it('should support a custom action params to provide the value to copy', async () => { + expect(handleEvent).not.toHaveBeenCalled(); + expect(writeText).not.toHaveBeenCalled(); + + await setup(` +
+ +
+ `); + + document.querySelector('button').click(); + + expect(handleEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'w-clipboard:copy' }), + ); + + await jest.runAllTimersAsync(); + + expect(writeText).toHaveBeenCalledWith('Copy me instead!'); + expect(handleEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'w-clipboard:copied', + detail: { clear: true, type: 'success' }, + }), + ); + }); + + it('should support falling back to the controlled element value if no other value is provided', async () => { + expect(handleEvent).not.toHaveBeenCalled(); + expect(writeText).not.toHaveBeenCalled(); + + await setup(` + + `); + + document + .querySelector('textarea') + .dispatchEvent(new CustomEvent('custom-event')); + + expect(handleEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'w-clipboard:copy' }), + ); + + await jest.runAllTimersAsync(); + + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining( + 'Copy the content inside the controlled element.', + ), + ); + + expect(handleEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'w-clipboard:copied', + detail: { clear: true, type: 'success' }, + }), + ); + }); + }); +}); diff --git a/client/src/controllers/ClipboardController.ts b/client/src/controllers/ClipboardController.ts new file mode 100644 index 000000000000..42aac3c8c8af --- /dev/null +++ b/client/src/controllers/ClipboardController.ts @@ -0,0 +1,61 @@ +import { Controller } from '@hotwired/stimulus'; + +type CopyOptions = { + /** Custom supplied value to copy to the clipboard. */ + value?: string; +}; + +/** + * Adds the ability for an element to copy the value from a target to the clipboard. + * + * @example + * ```html + *
+ * + * + *
+ * ``` + */ +export class ClipboardController extends Controller { + static targets = ['value']; + + declare readonly hasValueTarget: boolean; + declare readonly valueTarget: + | HTMLInputElement + | HTMLTextAreaElement + | HTMLSelectElement; + + /** + * Copies the value from either the Custom Event detail, Stimulus action params or + * the value target to the clipboard. If no value is found, nothing happens. + * If the clipboard is not available an error event is dispatched and it will + * intentionally fail silently. + */ + copy(event: CustomEvent & { params?: CopyOptions }) { + const { + value = this.hasValueTarget + ? this.valueTarget.value + : (this.element as HTMLInputElement).value || null, + } = { ...event.detail, ...event.params }; + + if (!value) return; + + const copyEvent = this.dispatch('copy'); + + if (copyEvent.defaultPrevented) return; + + new Promise((resolve, reject) => { + if (navigator.clipboard) { + navigator.clipboard.writeText(value).then(resolve, reject); + } else { + reject(); + } + }) + .then(() => + this.dispatch('copied', { detail: { clear: true, type: 'success' } }), + ) + .catch(() => + this.dispatch('error', { detail: { clear: true, type: 'error' } }), + ); + } +} diff --git a/client/src/controllers/MessagesController.test.js b/client/src/controllers/CloneController.test.js similarity index 52% rename from client/src/controllers/MessagesController.test.js rename to client/src/controllers/CloneController.test.js index 87a435a6fa76..49f281687e9e 100644 --- a/client/src/controllers/MessagesController.test.js +++ b/client/src/controllers/CloneController.test.js @@ -1,9 +1,10 @@ import { Application } from '@hotwired/stimulus'; -import { MessagesController } from './MessagesController'; +import { CloneController } from './CloneController'; jest.useFakeTimers(); +jest.spyOn(global, 'setTimeout'); -describe('MessagesController', () => { +describe('CloneController', () => { let application; afterEach(() => { @@ -13,24 +14,24 @@ describe('MessagesController', () => { describe('default behaviour', () => { const addedListener = jest.fn(); - document.addEventListener('w-messages:added', addedListener); + document.addEventListener('w-clone:added', addedListener); beforeAll(() => { application?.stop(); document.body.innerHTML = `
-
    -