From fe68ceb8ec5bb3cd4ea95ec64b3ff6a4783d182d Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Mon, 8 Jul 2024 12:40:34 +0200 Subject: [PATCH] First unified version --- .editorconfig | 35 + .github/workflows/backend.yml | 158 ++ .github/workflows/frontend.yml | 145 ++ .gitignore | 13 +- .pre-commit-config.yaml | 39 +- .vscode/settings.json | 5 + CHANGELOG.md | 5 + Makefile | 279 ++-- README.md | 339 +--- backend/.dockerignore | 14 + backend/.editorconfig | 54 + backend/.flake8 | 22 + backend/.gitignore | 52 + backend/.pre-commit-config.yaml | 94 ++ backend/CHANGES.md | 10 + backend/CONTRIBUTORS.md | 3 + backend/Dockerfile | 41 + backend/Dockerfile.acceptance | 43 + backend/LICENSE.GPL | 339 ++++ backend/LICENSE.md | 15 + backend/MANIFEST.in | 20 + backend/Makefile | 139 ++ backend/README.md | 33 + backend/constraints.txt | 1 + {cypress => backend/docs}/.gitkeep | 0 backend/instance.yaml | 3 + backend/mx.ini | 17 + backend/news/.changelog_template.jinja | 15 + backend/news/.gitkeep | 1 + backend/pyproject.toml | 170 ++ backend/requirements-docker.txt | 1 + backend/requirements.txt | 1 + backend/scripts/create_site.py | 72 + backend/setup.py | 113 ++ backend/src/collective/__init__.py | 1 + backend/src/collective/volto/__init__.py | 1 + .../collective/volto/formsupport/__init__.py | 9 + .../volto/formsupport/browser/__init__.py | 0 .../volto/formsupport/browser/configure.zcml | 46 + .../formsupport/browser/email_confirm_view.py | 18 + .../formsupport/browser/overrides}/.gitkeep | 0 .../formsupport/browser/send_mail_template.pt | 38 + .../browser/send_mail_template_table.pt | 57 + .../formsupport/browser/static}/.gitkeep | 0 .../browser/templates/email_confirm_view.pt | 407 +++++ .../volto/formsupport/captcha/__init__.py | 13 + .../volto/formsupport/captcha/configure.zcml | 56 + .../volto/formsupport/captcha/hcaptcha.py | 63 + .../volto/formsupport/captcha/honeypot.py | 43 + .../volto/formsupport/captcha/norobots.py | 68 + .../volto/formsupport/captcha/recaptcha.py | 57 + .../volto/formsupport/captcha/vocabularies.py | 15 + .../volto/formsupport/configure.zcml | 50 + .../volto/formsupport/datamanager/__init__.py | 0 .../volto/formsupport/datamanager/catalog.py | 118 ++ .../formsupport/datamanager/configure.zcml | 20 + .../volto/formsupport/interfaces.py | 46 + .../volto/formsupport/locales/README.rst | 37 + .../volto/formsupport/locales/__init__.py | 0 .../locales/collective.volto.formsupport.pot | 157 ++ .../collective.volto.formsupport.po | 154 ++ .../collective.volto.formsupport.po | 159 ++ .../collective.volto.formsupport.po | 154 ++ .../collective.volto.formsupport.po | 156 ++ .../volto/formsupport/locales/update.py | 72 + .../volto/formsupport/locales/update.sh | 12 + .../volto/formsupport/permissions.zcml | 13 + .../profiles/default/browserlayer.xml | 6 + .../formsupport/profiles/default/catalog.xml | 4 + .../formsupport/profiles/default/metadata.xml | 7 + .../profiles/default/registry/main.xml | 8 + .../formsupport/profiles/default/rolemap.xml | 7 + .../profiles/uninstall/browserlayer.xml | 6 + .../volto/formsupport/restapi/__init__.py | 0 .../volto/formsupport/restapi/configure.zcml | 12 + .../restapi/deserializer/__init__.py | 0 .../restapi/deserializer/blocks.py | 40 + .../restapi/deserializer/configure.zcml | 14 + .../restapi/serializer/__init__.py | 0 .../formsupport/restapi/serializer/blocks.py | 59 + .../restapi/serializer/configure.zcml | 16 + .../formsupport/restapi/services/__init__.py | 0 .../restapi/services/configure.zcml | 10 + .../restapi/services/form_data/__init__.py | 0 .../restapi/services/form_data/clear.py | 25 + .../restapi/services/form_data/configure.zcml | 38 + .../restapi/services/form_data/csv.py | 94 ++ .../restapi/services/form_data/form_data.py | 112 ++ .../restapi/services/submit_form/__init__.py | 0 .../services/submit_form/configure.zcml | 16 + .../restapi/services/submit_form/post.py | 541 +++++++ .../restapi/services/validation/__init__.py | 0 .../services/validation/configure.zcml | 24 + .../restapi/services/validation/email.py | 94 ++ .../volto/formsupport/scripts/__init__.py | 0 .../volto/formsupport/scripts/cleansing.py | 70 + .../volto/formsupport/setuphandlers.py | 25 + .../collective/volto/formsupport/testing.py | 82 + .../volto/formsupport/tests/__init__.py | 0 .../volto/formsupport/tests/file.pdf | Bin 0 -> 74429 bytes .../volto/formsupport/tests/test_captcha.py | 465 ++++++ .../tests/test_deserialize_block.py | 69 + .../volto/formsupport/tests/test_event.py | 139 ++ .../volto/formsupport/tests/test_honeypot.py | 345 ++++ .../tests/test_send_action_form.py | 1383 +++++++++++++++++ .../formsupport/tests/test_serialize_block.py | 231 +++ .../volto/formsupport/tests/test_setup.py | 85 + .../tests/test_store_action_form.py | 317 ++++ .../collective/volto/formsupport/upgrades.py | 233 +++ .../volto/formsupport/upgrades.zcml | 35 + .../src/collective/volto/formsupport/utils.py | 62 + backend/tests/conftest.py | 18 + backend/tests/setup/test_setup_install.py | 17 + backend/tests/setup/test_setup_uninstall.py | 19 + backend/tox.ini | 211 +++ backend/version.txt | 1 + dependabot.yml | 8 + docker-compose.yml | 106 ++ .eslintrc.js => frontend/.eslintrc.js | 0 .../.github}/workflows/acceptance.yml | 0 .../.github}/workflows/changelog.yml | 0 .../.github}/workflows/code.yml | 0 .../.github}/workflows/i18n.yml | 0 .../.github}/workflows/storybook.yml | 0 .../.github}/workflows/unit.yml | 0 frontend/.gitignore | 13 + .npmignore => frontend/.npmignore | 0 .npmrc => frontend/.npmrc | 0 frontend/.pre-commit-config.yaml | 27 + .prettierignore => frontend/.prettierignore | 0 .prettierrc => frontend/.prettierrc | 0 {.storybook => frontend/.storybook}/main.js | 0 .../.storybook}/preview.jsx | 0 .stylelintrc => frontend/.stylelintrc | 0 frontend/Makefile | 142 ++ frontend/README.md | 337 ++++ .../cypress.config.js | 0 frontend/cypress/.gitkeep | 0 .../cypress}/support/commands.js | 0 {cypress => frontend/cypress}/support/e2e.js | 0 frontend/cypress/tests/.gitkeep | 0 .../cypress}/tests/example.cy.js | 0 .../jest-addon.config.js | 0 .../mrs.developer.json | 0 package.json => frontend/package.json | 0 .../packages}/volto-form-block/.gitignore | 0 .../volto-form-block/.release-it.json | 0 .../packages}/volto-form-block/CHANGELOG.md | 0 .../volto-form-block/babel.config.js | 0 .../locales/de/LC_MESSAGES/volto.po | 0 .../locales/en/LC_MESSAGES/volto.po | 0 .../locales/es/LC_MESSAGES/volto.po | 0 .../locales/eu/LC_MESSAGES/volto.po | 0 .../locales/fr/LC_MESSAGES/volto.po | 0 .../locales/it/LC_MESSAGES/volto.po | 0 .../locales/ja/LC_MESSAGES/volto.po | 0 .../locales/nl/LC_MESSAGES/volto.po | 0 .../locales/pt/LC_MESSAGES/volto.po | 0 .../locales/pt_BR/LC_MESSAGES/volto.po | 0 .../locales/ro/LC_MESSAGES/volto.po | 0 .../volto-form-block/locales/volto.pot | 0 .../packages/volto-form-block/news/.gitkeep | 0 .../volto-form-block/news/109.internal | 0 .../packages}/volto-form-block/package.json | 0 .../packages/volto-form-block/public/.gitkeep | 0 .../volto-form-block/src/actions/index.js | 0 .../volto-form-block/src/components/Edit.jsx | 0 .../src/components/EditBlock.jsx | 0 .../volto-form-block/src/components/Field.css | 0 .../volto-form-block/src/components/Field.jsx | 0 .../FromSchemaExtender.js | 0 .../HiddenSchemaExtender.js | 0 .../SelectionSchemaExtender.js | 0 .../FieldTypeSchemaExtenders/index.js | 0 .../src/components/FormResult.jsx | 0 .../src/components/FormView.css | 0 .../src/components/FormView.jsx | 0 .../src/components/Sidebar.css | 0 .../src/components/Sidebar.jsx | 0 .../src/components/ValidateConfigForm.jsx | 0 .../volto-form-block/src/components/View.jsx | 0 .../src/components/Widget/Button.jsx | 0 .../src/components/Widget/Captcha.jsx | 0 .../components/Widget/CheckboxListWidget.css | 0 .../components/Widget/CheckboxListWidget.jsx | 0 .../src/components/Widget/CheckboxWidget.jsx | 0 .../src/components/Widget/DatetimeWidget.jsx | 0 .../src/components/Widget/EmailWidget.jsx | 0 .../src/components/Widget/FileWidget.jsx | 0 .../Widget/GoogleReCaptchaWidget.jsx | 0 .../src/components/Widget/HCaptchaWidget.jsx | 0 .../src/components/Widget/HiddenWidget.jsx | 0 .../Widget/HoneypotCaptchaWidget.css | 0 .../Widget/HoneypotCaptchaWidget.jsx | 0 .../Widget/NoRobotsCaptchaWidget.jsx | 0 .../src/components/Widget/OTPWidget.css | 0 .../src/components/Widget/OTPWidget.jsx | 0 .../src/components/Widget/RadioWidget.css | 0 .../src/components/Widget/RadioWidget.jsx | 0 .../src/components/Widget/SelectWidget.jsx | 0 .../src/components/Widget/TextWidget.jsx | 0 .../src/components/Widget/TextareaWidget.jsx | 0 .../src/components/Widget/index.js | 0 .../volto-form-block/src/components/index.js | 0 .../volto-form-block/src/components/utils.js | 0 .../volto-form-block/src/fieldSchema.js | 0 .../volto-form-block/src/formSchema.js | 0 .../src/helpers/react-select.js | 0 .../src/helpers/validators.js | 0 .../packages}/volto-form-block/src/index.js | 0 .../volto-form-block/src/reducers/index.js | 0 .../packages}/volto-form-block/towncrier.toml | 0 .../packages}/volto-form-block/tsconfig.json | 0 pnpm-lock.yaml => frontend/pnpm-lock.yaml | 574 +++++-- .../pnpm-workspace.yaml | 0 .../volto-form-block/frontend/.dockerignore | 6 + frontend/volto-form-block/frontend/Dockerfile | 30 + volto.config.js => frontend/volto.config.js | 0 version.txt | 1 + 219 files changed, 9926 insertions(+), 589 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/backend.yml create mode 100644 .github/workflows/frontend.yml create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md create mode 100644 backend/.dockerignore create mode 100644 backend/.editorconfig create mode 100644 backend/.flake8 create mode 100644 backend/.gitignore create mode 100644 backend/.pre-commit-config.yaml create mode 100644 backend/CHANGES.md create mode 100644 backend/CONTRIBUTORS.md create mode 100644 backend/Dockerfile create mode 100644 backend/Dockerfile.acceptance create mode 100644 backend/LICENSE.GPL create mode 100644 backend/LICENSE.md create mode 100644 backend/MANIFEST.in create mode 100644 backend/Makefile create mode 100644 backend/README.md create mode 100644 backend/constraints.txt rename {cypress => backend/docs}/.gitkeep (100%) create mode 100644 backend/instance.yaml create mode 100644 backend/mx.ini create mode 100644 backend/news/.changelog_template.jinja create mode 100644 backend/news/.gitkeep create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements-docker.txt create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/create_site.py create mode 100644 backend/setup.py create mode 100644 backend/src/collective/__init__.py create mode 100644 backend/src/collective/volto/__init__.py create mode 100644 backend/src/collective/volto/formsupport/__init__.py rename cypress/tests/.gitkeep => backend/src/collective/volto/formsupport/browser/__init__.py (100%) create mode 100644 backend/src/collective/volto/formsupport/browser/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/browser/email_confirm_view.py rename {packages/volto-form-block/news => backend/src/collective/volto/formsupport/browser/overrides}/.gitkeep (100%) create mode 100644 backend/src/collective/volto/formsupport/browser/send_mail_template.pt create mode 100644 backend/src/collective/volto/formsupport/browser/send_mail_template_table.pt rename {packages/volto-form-block/public => backend/src/collective/volto/formsupport/browser/static}/.gitkeep (100%) create mode 100644 backend/src/collective/volto/formsupport/browser/templates/email_confirm_view.pt create mode 100644 backend/src/collective/volto/formsupport/captcha/__init__.py create mode 100644 backend/src/collective/volto/formsupport/captcha/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/captcha/hcaptcha.py create mode 100644 backend/src/collective/volto/formsupport/captcha/honeypot.py create mode 100644 backend/src/collective/volto/formsupport/captcha/norobots.py create mode 100644 backend/src/collective/volto/formsupport/captcha/recaptcha.py create mode 100644 backend/src/collective/volto/formsupport/captcha/vocabularies.py create mode 100644 backend/src/collective/volto/formsupport/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/datamanager/__init__.py create mode 100644 backend/src/collective/volto/formsupport/datamanager/catalog.py create mode 100644 backend/src/collective/volto/formsupport/datamanager/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/interfaces.py create mode 100644 backend/src/collective/volto/formsupport/locales/README.rst create mode 100644 backend/src/collective/volto/formsupport/locales/__init__.py create mode 100644 backend/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot create mode 100644 backend/src/collective/volto/formsupport/locales/de/LC_MESSAGES/collective.volto.formsupport.po create mode 100644 backend/src/collective/volto/formsupport/locales/es/LC_MESSAGES/collective.volto.formsupport.po create mode 100644 backend/src/collective/volto/formsupport/locales/it/LC_MESSAGES/collective.volto.formsupport.po create mode 100644 backend/src/collective/volto/formsupport/locales/pt_BR/LC_MESSAGES/collective.volto.formsupport.po create mode 100644 backend/src/collective/volto/formsupport/locales/update.py create mode 100755 backend/src/collective/volto/formsupport/locales/update.sh create mode 100644 backend/src/collective/volto/formsupport/permissions.zcml create mode 100644 backend/src/collective/volto/formsupport/profiles/default/browserlayer.xml create mode 100644 backend/src/collective/volto/formsupport/profiles/default/catalog.xml create mode 100644 backend/src/collective/volto/formsupport/profiles/default/metadata.xml create mode 100644 backend/src/collective/volto/formsupport/profiles/default/registry/main.xml create mode 100644 backend/src/collective/volto/formsupport/profiles/default/rolemap.xml create mode 100644 backend/src/collective/volto/formsupport/profiles/uninstall/browserlayer.xml create mode 100644 backend/src/collective/volto/formsupport/restapi/__init__.py create mode 100644 backend/src/collective/volto/formsupport/restapi/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/restapi/deserializer/__init__.py create mode 100644 backend/src/collective/volto/formsupport/restapi/deserializer/blocks.py create mode 100644 backend/src/collective/volto/formsupport/restapi/deserializer/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/restapi/serializer/__init__.py create mode 100644 backend/src/collective/volto/formsupport/restapi/serializer/blocks.py create mode 100644 backend/src/collective/volto/formsupport/restapi/serializer/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/restapi/services/__init__.py create mode 100644 backend/src/collective/volto/formsupport/restapi/services/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/restapi/services/form_data/__init__.py create mode 100644 backend/src/collective/volto/formsupport/restapi/services/form_data/clear.py create mode 100644 backend/src/collective/volto/formsupport/restapi/services/form_data/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/restapi/services/form_data/csv.py create mode 100644 backend/src/collective/volto/formsupport/restapi/services/form_data/form_data.py create mode 100644 backend/src/collective/volto/formsupport/restapi/services/submit_form/__init__.py create mode 100644 backend/src/collective/volto/formsupport/restapi/services/submit_form/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/restapi/services/submit_form/post.py create mode 100644 backend/src/collective/volto/formsupport/restapi/services/validation/__init__.py create mode 100644 backend/src/collective/volto/formsupport/restapi/services/validation/configure.zcml create mode 100644 backend/src/collective/volto/formsupport/restapi/services/validation/email.py create mode 100644 backend/src/collective/volto/formsupport/scripts/__init__.py create mode 100644 backend/src/collective/volto/formsupport/scripts/cleansing.py create mode 100644 backend/src/collective/volto/formsupport/setuphandlers.py create mode 100644 backend/src/collective/volto/formsupport/testing.py create mode 100644 backend/src/collective/volto/formsupport/tests/__init__.py create mode 100644 backend/src/collective/volto/formsupport/tests/file.pdf create mode 100644 backend/src/collective/volto/formsupport/tests/test_captcha.py create mode 100644 backend/src/collective/volto/formsupport/tests/test_deserialize_block.py create mode 100644 backend/src/collective/volto/formsupport/tests/test_event.py create mode 100644 backend/src/collective/volto/formsupport/tests/test_honeypot.py create mode 100644 backend/src/collective/volto/formsupport/tests/test_send_action_form.py create mode 100644 backend/src/collective/volto/formsupport/tests/test_serialize_block.py create mode 100644 backend/src/collective/volto/formsupport/tests/test_setup.py create mode 100644 backend/src/collective/volto/formsupport/tests/test_store_action_form.py create mode 100644 backend/src/collective/volto/formsupport/upgrades.py create mode 100644 backend/src/collective/volto/formsupport/upgrades.zcml create mode 100644 backend/src/collective/volto/formsupport/utils.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/setup/test_setup_install.py create mode 100644 backend/tests/setup/test_setup_uninstall.py create mode 100644 backend/tox.ini create mode 100644 backend/version.txt create mode 100644 dependabot.yml create mode 100644 docker-compose.yml rename .eslintrc.js => frontend/.eslintrc.js (100%) rename {.github => frontend/.github}/workflows/acceptance.yml (100%) rename {.github => frontend/.github}/workflows/changelog.yml (100%) rename {.github => frontend/.github}/workflows/code.yml (100%) rename {.github => frontend/.github}/workflows/i18n.yml (100%) rename {.github => frontend/.github}/workflows/storybook.yml (100%) rename {.github => frontend/.github}/workflows/unit.yml (100%) create mode 100644 frontend/.gitignore rename .npmignore => frontend/.npmignore (100%) rename .npmrc => frontend/.npmrc (100%) create mode 100644 frontend/.pre-commit-config.yaml rename .prettierignore => frontend/.prettierignore (100%) rename .prettierrc => frontend/.prettierrc (100%) rename {.storybook => frontend/.storybook}/main.js (100%) rename {.storybook => frontend/.storybook}/preview.jsx (100%) rename .stylelintrc => frontend/.stylelintrc (100%) create mode 100644 frontend/Makefile create mode 100644 frontend/README.md rename cypress.config.js => frontend/cypress.config.js (100%) create mode 100644 frontend/cypress/.gitkeep rename {cypress => frontend/cypress}/support/commands.js (100%) rename {cypress => frontend/cypress}/support/e2e.js (100%) create mode 100644 frontend/cypress/tests/.gitkeep rename {cypress => frontend/cypress}/tests/example.cy.js (100%) rename jest-addon.config.js => frontend/jest-addon.config.js (100%) rename mrs.developer.json => frontend/mrs.developer.json (100%) rename package.json => frontend/package.json (100%) rename {packages => frontend/packages}/volto-form-block/.gitignore (100%) rename {packages => frontend/packages}/volto-form-block/.release-it.json (100%) rename {packages => frontend/packages}/volto-form-block/CHANGELOG.md (100%) rename {packages => frontend/packages}/volto-form-block/babel.config.js (100%) rename {packages => frontend/packages}/volto-form-block/locales/de/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/en/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/es/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/eu/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/fr/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/it/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/ja/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/nl/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/pt/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/pt_BR/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/ro/LC_MESSAGES/volto.po (100%) rename {packages => frontend/packages}/volto-form-block/locales/volto.pot (100%) create mode 100644 frontend/packages/volto-form-block/news/.gitkeep rename {packages => frontend/packages}/volto-form-block/news/109.internal (100%) rename {packages => frontend/packages}/volto-form-block/package.json (100%) create mode 100644 frontend/packages/volto-form-block/public/.gitkeep rename {packages => frontend/packages}/volto-form-block/src/actions/index.js (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Edit.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/EditBlock.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Field.css (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Field.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/FieldTypeSchemaExtenders/FromSchemaExtender.js (100%) rename {packages => frontend/packages}/volto-form-block/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js (100%) rename {packages => frontend/packages}/volto-form-block/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js (100%) rename {packages => frontend/packages}/volto-form-block/src/components/FieldTypeSchemaExtenders/index.js (100%) rename {packages => frontend/packages}/volto-form-block/src/components/FormResult.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/FormView.css (100%) rename {packages => frontend/packages}/volto-form-block/src/components/FormView.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Sidebar.css (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Sidebar.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/ValidateConfigForm.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/View.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/Button.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/Captcha.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/CheckboxListWidget.css (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/CheckboxListWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/CheckboxWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/DatetimeWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/EmailWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/FileWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/GoogleReCaptchaWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/HCaptchaWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/HiddenWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.css (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/NoRobotsCaptchaWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/OTPWidget.css (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/OTPWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/RadioWidget.css (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/RadioWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/SelectWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/TextWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/TextareaWidget.jsx (100%) rename {packages => frontend/packages}/volto-form-block/src/components/Widget/index.js (100%) rename {packages => frontend/packages}/volto-form-block/src/components/index.js (100%) rename {packages => frontend/packages}/volto-form-block/src/components/utils.js (100%) rename {packages => frontend/packages}/volto-form-block/src/fieldSchema.js (100%) rename {packages => frontend/packages}/volto-form-block/src/formSchema.js (100%) rename {packages => frontend/packages}/volto-form-block/src/helpers/react-select.js (100%) rename {packages => frontend/packages}/volto-form-block/src/helpers/validators.js (100%) rename {packages => frontend/packages}/volto-form-block/src/index.js (100%) rename {packages => frontend/packages}/volto-form-block/src/reducers/index.js (100%) rename {packages => frontend/packages}/volto-form-block/towncrier.toml (100%) rename {packages => frontend/packages}/volto-form-block/tsconfig.json (100%) rename pnpm-lock.yaml => frontend/pnpm-lock.yaml (98%) rename pnpm-workspace.yaml => frontend/pnpm-workspace.yaml (100%) create mode 100644 frontend/volto-form-block/frontend/.dockerignore create mode 100644 frontend/volto-form-block/frontend/Dockerfile rename volto.config.js => frontend/volto.config.js (100%) create mode 100644 version.txt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a769f32 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ + +# EditorConfig Configurtaion file, for more details see: +# https://EditorConfig.org +# EditorConfig is a convention description, that could be interpreted +# by multiple editors to enforce common coding conventions for specific +# file types + +# top-most EditorConfig file: +# Will ignore other EditorConfig files in Home directory or upper tree level. +root = true + + +[*] # For All Files +# Unix-style newlines with a newline ending every file +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +# Set default charset +charset = utf-8 +# Indent style default +indent_style = space + +[*.{py,cfg,ini}] +# 4 space indentation +indent_size = 4 + +[*.{html,dtml,pt,zpt,xml,zcml,js,json,ts,less,css,sass,yml,yaml}] +# 2 space indentation +indent_size = 2 + +[{Makefile,.gitmodules}] +# Tab indentation (no size specified, but view as 4 spaces) +indent_style = tab +indent_size = unset +tab_width = unset diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..be35851 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,158 @@ +name: Backend CI + +on: + push: + paths: + - "backend/**" + - ".github/workflows/backend.yml" + workflow_dispatch: + +env: + IMAGE_NAME_PREFIX: ghcr.io/collective/volto-form-block + IMAGE_NAME_SUFFIX: backend + PYTHON_VERSION: "3.11" + +jobs: + + meta: + runs-on: ubuntu-latest + outputs: + PLONE_VERSION: ${{ steps.vars.outputs.PLONE_VERSION }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set Env Vars + id: vars + run: | + echo "PLONE_VERSION=$(cat backend/version.txt)" >> $GITHUB_OUTPUT + + black: + runs-on: ubuntu-latest + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Run check + uses: plone/code-analysis-action@v2 + with: + base_dir: 'backend' + check: 'black' + + flake8: + runs-on: ubuntu-latest + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Run check + uses: plone/code-analysis-action@v2 + with: + base_dir: 'backend' + check: 'flake8' + + isort: + runs-on: ubuntu-latest + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Run check + uses: plone/code-analysis-action@v2 + with: + base_dir: 'backend' + check: 'isort' + + zpretty: + runs-on: ubuntu-latest + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Run check + uses: plone/code-analysis-action@v2 + with: + base_dir: 'backend' + check: 'zpretty' + + tests: + runs-on: ubuntu-latest + needs: + - meta + defaults: + run: + working-directory: ./backend + + steps: + - uses: actions/checkout@v4 + + - name: Setup Plone ${{ needs.meta.outputs.PLONE_VERSION }} with Python ${{ env.PYTHON_VERSION }} + uses: plone/setup-plone@v2.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + plone-version: ${{ needs.meta.outputs.PLONE_VERSION }} + + - name: Install package + run: | + pip install mxdev + mxdev -c mx.ini + pip install -r requirements-mxdev.txt + + - name: Run tests + run: | + pytest --disable-warnings src/collective.voltoformblock/tests + + release: + runs-on: ubuntu-latest + needs: + - black + - flake8 + - isort + - zpretty + - tests + permissions: + contents: read + packages: write + + steps: + + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.IMAGE_NAME_PREFIX }}-${{ env.IMAGE_NAME_SUFFIX }} + labels: | + org.label-schema.docker.cmd=docker run -d -p 8080:8080 ${{ env.IMAGE_NAME_PREFIX }}-${{ env.IMAGE_NAME_SUFFIX }}:latest + flavor: + latest=false + tags: | + type=ref,event=branch + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + platforms: linux/amd64 + context: backend + file: backend/Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..742e7cc --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,145 @@ +name: Frontend CI + +on: + push: + paths: + - "frontend/**" + - ".github/workflows/frontend.yml" + workflow_dispatch: + +env: + IMAGE_NAME_PREFIX: ghcr.io/collective/volto-form-block + IMAGE_NAME_SUFFIX: frontend + NODE_VERSION: 20.x + +defaults: + run: + working-directory: ./frontend + +jobs: + meta: + runs-on: ubuntu-latest + outputs: + BASE_TAG: ${{ steps.vars.outputs.BASE_TAG }} + VOLTO_VERSION: ${{ steps.vars.outputs.VOLTO_VERSION }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Compute several vars needed for the build + id: vars + run: | + echo 'BASE_TAG=sha-$(git rev-parse --short HEAD)' >> $GITHUB_OUTPUT + python3 -c 'import json; data = json.load(open("./mrs.developer.json")); print("VOLTO_VERSION=" + data["core"].get("tag") or "latest")' >> $GITHUB_OUTPUT + - name: Test vars + run: | + echo 'BASE_TAG=${{ steps.vars.outputs.BASE_TAG }}' + echo 'VOLTO_VERSION=${{ steps.vars.outputs.VOLTO_VERSION }}' + + code-analysis: + runs-on: ubuntu-latest + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Use Node.js ${{ env.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Enable corepack + run: corepack enable + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: make install + + - name: Linting + id: lint + if: ${{ success() || failure() }} + run: make lint + + - name: i18n sync + id: i18n + if: ${{ success() || failure() }} + run: make ci-i18n + + - name: Unit Tests + id: unit + if: ${{ success() || failure() }} + run: make test + + - name: Report + if: ${{ success() || failure() }} + run: | + echo '# Code Analysis' >> $GITHUB_STEP_SUMMARY + echo '| Test | Status |' >> $GITHUB_STEP_SUMMARY + echo '| --- | --- |' >> $GITHUB_STEP_SUMMARY + echo '| Lint | ${{ steps.lint.conclusion == 'failure' && '❌' || ' ✅' }} |' >> $GITHUB_STEP_SUMMARY + echo '| i18n | ${{ steps.i18n.conclusion == 'failure' && '❌' || ' ✅' }} |' >> $GITHUB_STEP_SUMMARY + echo '| Unit Tests | ${{ steps.unit.conclusion == 'failure' && '❌' || ' ✅' }} |' >> $GITHUB_STEP_SUMMARY + + release: + runs-on: ubuntu-latest + needs: + - meta + - code-analysis + permissions: + contents: read + packages: write + + steps: + + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.IMAGE_NAME_PREFIX }}-${{ env.IMAGE_NAME_SUFFIX }} + labels: | + org.label-schema.docker.cmd=docker run -d -p 3000:3000 ${{ env.IMAGE_NAME_PREFIX }}-${{ env.IMAGE_NAME_SUFFIX }}:latest + flavor: + latest=false + tags: | + type=ref,event=branch + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + platforms: linux/amd64 + context: frontend/ + file: frontend/Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VOLTO_VERSION=${{ needs.meta.outputs.VOLTO_VERSION }} diff --git a/.gitignore b/.gitignore index cdcd937..2073158 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,4 @@ -.*project -.settings/ -.vscode -*~ -acceptance/cypress/videos/ -acceptance/node_modules -.storybook-build -build -core +!.vscode node_modules -results -yarn.lock +/core /public diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c7d331..d933aa8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,27 +1,16 @@ repos: - - repo: local + - repo: https://github.com/ddanier/sub-pre-commit.git + rev: v3.7.1 # MUST match your pre-commit version hooks: - - id: prettier - name: prettier - entry: pnpm exec prettier --write - language: system - files: '^packages/.*/src/.*/?.*.(js|jsx|ts|tsx)$' - types: [file] - - id: eslint - name: eslint - entry: bash -c "VOLTOCONFIG=$(pwd)/volto.config.js pnpm exec eslint --max-warnings=0 --fix" - language: system - files: '^packages/.*/src/.*/?.*.(js|jsx|ts|tsx)$' - types: [file] - - id: stylelint - name: stylelint - entry: pnpm exec stylelint --fix - language: system - files: '^packages/.*/src/.*/?.*.(css|scss|less)$' - types: [file] - - id: i18n - name: i18n - entry: make ci-i18n - language: system - files: '^packages/.*/src/.*/?.*.(js|jsx|ts|tsx)$' - types: [file] + - id: sub-pre-commit + alias: backend + name: "pre-commit for backend/" + args: ["-p", "backend/"] + files: "^backend/.*" + stages: ["commit"] + - id: sub-pre-commit + alias: frontend + name: "pre-commit for frontend" + args: ["-p", "frontend"] + files: "^frontend/.*" + stages: ["commit"] diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..04f8089 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "eslint.workingDirectories": ["./frontend"], + "flake8.cwd": "${workspaceFolder}/backend", + "flake8.args": ["--config=pyproject.toml"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9186d30 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changes + +## 1.0.0 (2024-07-08) + +- Initial version [collective] diff --git a/Makefile b/Makefile index 1985842..92cae4c 100644 --- a/Makefile +++ b/Makefile @@ -2,15 +2,22 @@ # https://tech.davis-hansson.com/p/make/ SHELL:=bash .ONESHELL: -.SHELLFLAGS:=-eu -o pipefail -c +.SHELLFLAGS:=-xeu -o pipefail -O inherit_errexit -c .SILENT: .DELETE_ON_ERROR: MAKEFLAGS+=--warn-undefined-variables MAKEFLAGS+=--no-builtin-rules CURRENT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) +GIT_FOLDER=$(CURRENT_DIR)/.git + +PROJECT_NAME=volto-form-block +STACK_NAME=volto-form-block-example-com + +VOLTO_VERSION = $(shell cat frontend/mrs.developer.json | python -c "import sys, json; print(json.load(sys.stdin)['core']['tag'])") +PLONE_VERSION=$(shell cat backend/version.txt) -# Recipe snippets for reuse +PRE_COMMIT=pipx run --spec 'pre-commit==3.7.1' pre-commit # We like colors # From: https://coderwall.com/p/izxssa/colored-makefile-for-golang-projects @@ -19,122 +26,188 @@ GREEN=`tput setaf 2` RESET=`tput sgr0` YELLOW=`tput setaf 3` -GIT_FOLDER=$(CURRENT_DIR)/.git -PRE_COMMIT=pipx run --spec 'pre-commit==3.7.1' pre-commit - -PLONE_VERSION=6 -DOCKER_IMAGE=plone/server-dev:${PLONE_VERSION} -DOCKER_IMAGE_ACCEPTANCE=plone/server-acceptance:${PLONE_VERSION} - -ADDON_NAME='volto-form-block' +.PHONY: all +all: install +# Add the following 'help' target to your Makefile +# And add help text after each target name starting with '\#\#' .PHONY: help -help: ## Show this help - @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/\\x1b[36m\1\\x1b[m:\2/' | column -c2 -t -s :)" - -# Dev Helpers +help: ## This help message + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +########################################### +# Frontend +########################################### +.PHONY: frontend-install +frontend-install: ## Install React Frontend + $(MAKE) -C "./frontend/" install + +.PHONY: frontend-build +frontend-build: ## Build React Frontend + $(MAKE) -C "./frontend/" build + +.PHONY: frontend-start +frontend-start: ## Start React Frontend + $(MAKE) -C "./frontend/" start + +.PHONY: frontend-test +frontend-test: ## Test frontend codebase + @echo "Test frontend" + $(MAKE) -C "./frontend/" test + +########################################### +# Backend +########################################### +.PHONY: backend-install +backend-install: ## Create virtualenv and install Plone + $(MAKE) -C "./backend/" install + $(MAKE) backend-create-site + +.PHONY: backend-build +backend-build: ## Build Backend + $(MAKE) -C "./backend/" install + +.PHONY: backend-create-site +backend-create-site: ## Create a Plone site with default content + $(MAKE) -C "./backend/" create-site + +.PHONY: backend-update-example-content +backend-update-example-content: ## Export example content inside package + $(MAKE) -C "./backend/" update-example-content + +.PHONY: backend-start +backend-start: ## Start Plone Backend + $(MAKE) -C "./backend/" start + +.PHONY: backend-test +backend-test: ## Test backend codebase + @echo "Test backend" + $(MAKE) -C "./backend/" test .PHONY: install -install: ## Installs the add-on in a development environment - @echo "$(GREEN)Install$(RESET)" +install: ## Install + @echo "Install Backend & Frontend" if [ -d $(GIT_FOLDER) ]; then $(PRE_COMMIT) install; else echo "$(RED) Not installing pre-commit$(RESET)";fi - pnpm dlx mrs-developer missdev --no-config --fetch-https - pnpm i - make build-deps + $(MAKE) backend-install + $(MAKE) frontend-install .PHONY: start -start: ## Starts Volto, allowing reloading of the add-on during development - pnpm start - -.PHONY: build -build: ## Build a production bundle for distribution of the project with the add-on - pnpm build - -core/packages/registry/dist: core/packages/registry/src - pnpm --filter @plone/registry build - -core/packages/components/dist: core/packages/components/src - pnpm --filter @plone/components build - -.PHONY: build-deps -build-deps: core/packages/registry/dist core/packages/components/dist ## Build dependencies +start: ## Start + @echo "Starting application" + $(MAKE) backend-start + $(MAKE) frontend-start + +.PHONY: clean +clean: ## Clean installation + @echo "Clean installation" + $(MAKE) -C "./backend/" clean + $(MAKE) -C "./frontend/" clean + +.PHONY: check +check: ## Lint and Format codebase + @echo "Lint and Format codebase" + $(PRE_COMMIT) run -a .PHONY: i18n -i18n: ## Sync i18n - pnpm --filter $(ADDON_NAME) i18n - -.PHONY: ci-i18n -ci-i18n: ## Check if i18n is not synced - pnpm --filter $(ADDON_NAME) i18n && git diff -G'^[^\"POT]' --exit-code - -.PHONY: format -format: ## Format codebase - pnpm prettier:fix - pnpm lint:fix - pnpm stylelint:fix - -.PHONY: lint -lint: ## Lint, or catch and remove problems, in code base - pnpm lint - pnpm prettier - pnpm stylelint --allow-empty-input - -.PHONY: release -release: ## Release the add-on on npmjs.org - pnpm release - -.PHONY: release-dry-run -release-dry-run: ## Dry-run the release of the add-on on npmjs.org - pnpm release +i18n: ## Update locales + @echo "Update locales" + $(MAKE) -C "./backend/" i18n + $(MAKE) -C "./frontend/" i18n .PHONY: test -test: ## Run unit tests - pnpm test - -.PHONY: test-ci -ci-test: ## Run unit tests in CI - # Unit Tests need the i18n to be built - VOLTOCONFIG=$(pwd)/volto.config.js pnpm --filter @plone/volto i18n - CI=1 RAZZLE_JEST_CONFIG=$(CURRENT_DIR)/jest-addon.config.js pnpm --filter @plone/volto test -- --passWithNoTests - -.PHONY: backend-docker-start -backend-docker-start: ## Starts a Docker-based backend for development - @echo "$(GREEN)==> Start Docker-based Plone Backend$(RESET)" - docker run -it --rm --name=backend -p 8080:8080 -e SITE=Plone $(DOCKER_IMAGE) - -## Storybook -.PHONY: storybook-start -storybook-start: ## Start Storybook server on port 6006 - @echo "$(GREEN)==> Start Storybook$(RESET)" - pnpm run storybook - -.PHONY: storybook-build -storybook-build: ## Build Storybook - @echo "$(GREEN)==> Build Storybook$(RESET)" - mkdir -p $(CURRENT_DIR)/.storybook-build - pnpm run storybook-build -o $(CURRENT_DIR)/.storybook-build +test: backend-test frontend-test ## Test codebase + +.PHONY: build-images +build-images: ## Build docker images + @echo "Build" + $(MAKE) -C "./backend/" build-image + $(MAKE) -C "./frontend/" build-image + +## Docker stack +.PHONY: stack-start +stack-start: ## Local Stack: Start Services + @echo "Start local Docker stack" + VOLTO_VERSION=$(VOLTO_VERSION) PLONE_VERSION=$(PLONE_VERSION) docker compose -f docker-compose.yml up -d --build + @echo "Now visit: http://volto-form-block.localhost" + +.PHONY: start-stack +stack-create-site: ## Local Stack: Create a new site + @echo "Create a new site in the local Docker stack" + @docker compose -f docker-compose.yml exec backend ./docker-entrypoint.sh create-site + +.PHONY: start-ps +stack-status: ## Local Stack: Check Status + @echo "Check the status of the local Docker stack" + @docker compose -f docker-compose.yml ps + +.PHONY: stack-stop +stack-stop: ## Local Stack: Stop Services + @echo "Stop local Docker stack" + @docker compose -f docker-compose.yml stop + +.PHONY: stack-rm +stack-rm: ## Local Stack: Remove Services and Volumes + @echo "Remove local Docker stack" + @docker compose -f docker-compose.yml down + @echo "Remove local volume data" + @docker volume rm $(PROJECT_NAME)_vol-site-data ## Acceptance -.PHONY: acceptance-frontend-dev-start -acceptance-frontend-dev-start: ## Start acceptance frontend in development mode - RAZZLE_API_PATH=http://127.0.0.1:55001/plone pnpm start - -.PHONY: acceptance-frontend-prod-start -acceptance-frontend-prod-start: ## Start acceptance frontend in production mode - RAZZLE_API_PATH=http://127.0.0.1:55001/plone pnpm build && pnpm start:prod +.PHONY: acceptance-backend-dev-start +acceptance-backend-dev-start: ## Build Acceptance Servers + @echo "Build acceptance backend" + $(MAKE) -C "./backend/" acceptance-backend-start -.PHONY: acceptance-backend-start -acceptance-backend-start: ## Start backend acceptance server - docker run -it --rm -p 55001:55001 $(DOCKER_IMAGE_ACCEPTANCE) - -.PHONY: ci-acceptance-backend-start -ci-acceptance-backend-start: ## Start backend acceptance server in headless mode for CI - docker run -i --rm -p 55001:55001 $(DOCKER_IMAGE_ACCEPTANCE) +.PHONY: acceptance-frontend-dev-start +acceptance-frontend-dev-start: ## Build Acceptance Servers + @echo "Build acceptance backend" + $(MAKE) -C "./frontend/" acceptance-frontend-dev-start .PHONY: acceptance-test -acceptance-test: ## Start Cypress in interactive mode - pnpm --filter @plone/volto exec cypress open --config-file $(CURRENT_DIR)/cypress.config.js --config specPattern=$(CURRENT_DIR)'/cypress/tests/**/*.{js,jsx,ts,tsx}' +acceptance-test: ## Start Acceptance tests in interactive mode + @echo "Build acceptance backend" + $(MAKE) -C "./frontend/" acceptance-test + +# Build Docker images +.PHONY: acceptance-frontend-image-build +acceptance-frontend-image-build: ## Build Acceptance frontend server image + @echo "Build acceptance frontend" + @docker build frontend -t collective/volto-form-block-frontend:acceptance -f frontend/Dockerfile --build-arg VOLTO_VERSION=$(VOLTO_VERSION) + +.PHONY: acceptance-backend-image-build +acceptance-backend-image-build: ## Build Acceptance backend server image + @echo "Build acceptance backend" + @docker build backend -t collective/volto-form-block-backend:acceptance -f backend/Dockerfile.acceptance --build-arg PLONE_VERSION=$(PLONE_VERSION) + +.PHONY: acceptance-images-build +acceptance-images-build: ## Build Acceptance frontend/backend images + $(MAKE) acceptance-backend-image-build + $(MAKE) acceptance-frontend-image-build + +.PHONY: acceptance-frontend-container-start +acceptance-frontend-container-start: ## Start Acceptance frontend container + @echo "Start acceptance frontend" + @docker run --rm -p 3000:3000 --name volto-form-block-frontend-acceptance --link volto-form-block-backend-acceptance:backend -e RAZZLE_API_PATH=http://localhost:55001/plone -e RAZZLE_INTERNAL_API_PATH=http://backend:55001/plone -d collective/volto-form-block-frontend:acceptance + +.PHONY: acceptance-backend-container-start +acceptance-backend-container-start: ## Start Acceptance backend container + @echo "Start acceptance backend" + @docker run --rm -p 55001:55001 --name volto-form-block-backend-acceptance -d collective/volto-form-block-backend:acceptance + +.PHONY: acceptance-containers-start +acceptance-containers-start: ## Start Acceptance containers + $(MAKE) acceptance-backend-container-start + $(MAKE) acceptance-frontend-container-start + +.PHONY: acceptance-containers-stop +acceptance-containers-stop: ## Stop Acceptance containers + @echo "Stop acceptance containers" + @docker stop volto-form-block-frontend-acceptance + @docker stop volto-form-block-backend-acceptance .PHONY: ci-acceptance-test -ci-acceptance-test: ## Run cypress tests in headless mode for CI - pnpm --filter @plone/volto exec cypress run --config-file $(CURRENT_DIR)/cypress.config.js --config specPattern=$(CURRENT_DIR)'/cypress/tests/**/*.{js,jsx,ts,tsx}' +ci-acceptance-test: ## Run Acceptance tests in ci mode + $(MAKE) acceptance-containers-start + pnpm dlx wait-on --httpTimeout 20000 http-get://localhost:55001/plone http://localhost:3000 + $(MAKE) -C "./frontend/" ci-acceptance-test + $(MAKE) acceptance-containers-stop diff --git a/README.md b/README.md index d53e637..0eb512d 100644 --- a/README.md +++ b/README.md @@ -1,337 +1,110 @@ -# Volto Add-on (volto-form-block) +# Project Title 🚀 -Volto addon which adds a customizable form using a block. -Intended to be used with [collective.volto.formsupport](https://github.com/collective/collective.volto.formsupport). +[![Built with Cookieplone](https://img.shields.io/badge/built%20with-Cookieplone-0083be.svg?logo=cookiecutter)](https://github.com/plone/cookiecutter-plone/) +[![Black code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) +[![Backend Tests](https://github.com/collective/volto-form-block/actions/workflows/backend.yml/badge.svg)](https://github.com/collective/volto-form-block/actions/workflows/backend.yml) +[![Frontend Tests](https://github.com/collective/volto-form-block/actions/workflows/frontend.yml/badge.svg)](https://github.com/collective/volto-form-block/actions/workflows/frontend.yml) -[![npm](https://img.shields.io/npm/v/volto-form-block)](https://www.npmjs.com/package/volto-form-block) -[![](https://img.shields.io/badge/-Storybook-ff4785?logo=Storybook&logoColor=white&style=flat-square)](https://collective.github.io/volto-form-block/) -[![Code analysis checks](https://github.com/collective/volto-form-block/actions/workflows/code.yml/badge.svg)](https://github.com/collective/volto-form-block/actions/workflows/code.yml) -[![Unit tests](https://github.com/collective/volto-form-block/actions/workflows/unit.yml/badge.svg)](https://github.com/collective/volto-form-block/actions/workflows/unit.yml) +A new project using Plone 6. -## Compatibility +## Quick Start 🏁 -> **Note**: Since version v2.0.0 of this addon, it's required [collective.volto.formsupport](https://github.com/collective/collective.volto.formsupport) 2.0.0 or higher (and its upgrade steps). -> -> **Note**: Since version v2.1.2 of this addon, it's required Volto 14.2.0 -> -> **Note**: Since version v3.0.0 of this addon, it's required Volto >= 16.0.0-alpha.38 +### Prerequisites ✅ -## Features +Ensure you have the following installed: -This addon will add in your project the Form block and the needed reducers. +- Python 3.11 🐍 +- Node 20 🟩 +- pnpm 🧶 +- Docker 🐳 -Form block in chooser +### Installation 🔧 -![Form block view](./docs/form-block-view.png) +1. Clone the repository: -Using the engine of subblocks, you can manage form fields adding, sorting and deleting items. - -For each field, you can select the field type from: - -- Text -- Textarea -- Select -- Single choice (radio buttons) -- Multiple choice (checkbox buttons) -- Checkbox -- Date picker -- File upload with DnD -- E-mail -- Static rich text (not a fillable field, just text to display between other fields) - -For every field you can set a label and a help text. -For select, radio and checkbox fields, you can select a list of values. - -## Captcha verification - -This form addon is configured to work with [HCaptcha](https://www.hcaptcha.com), [ReCaptcha](https://www.google.com/recaptcha/) and -[NoRobot](https://github.com/collective/collective.z3cform.norobots) to prevent spam. - -In order to make one of these integrations work, you need to add -[https://github.com/plone/plone.formwidget.hcaptcha](https://github.com/plone/plone.formwidget.hcaptcha) and/or -[https://github.com/plone/plone.formwidget.recaptcha](https://github.com/plone/plone.formwidget.recaptcha) and/or -[https://github.com/collective/collective.z3cform.norobots](https://github.com/collective/collective.z3cform.norobots) -Plone addon and configure public and private keys in controlpanels. - -### HCaptcha - -With HCaptcha integration, you also have an additional option in the sidebar in 'Captcha provider' to enable or disable the invisible captcha (see implications [here](https://docs.hcaptcha.com/faq#do-i-need-to-display-anything-on-the-page-when-using-hcaptcha-in-invisible-mode)). - -In some test scenarios it's found that the "Passing Threshold" of HCaptcha must be configured as "Auto" to get the best results. In some test cases if one sets the Threshold to "Moderate" HCaptcha starts to fail. - -### OTP email validation - -To prevent sending spam emails to users via the email address configured as sender, the 'email' fields type flagged as BCC will require the user to enter an OTP code received at the address entered in the field when user fills out the form. - -## Export - -With backend support, you can store data submitted from the form. -In Edit, you can export and clear stored data from the sidebar. - -Form export - -## Additional fields - -In addition to the fields described above, you can add any field you want. -If you need a field that is not supported, PRs are always welcome, but if you have to use a custom field tailored on your project needs, then you can add additional custom fields. - -```jsx -config.blocks.blocksConfig.form.additionalFields.push({ - id: 'field type id', - label: - intl.formatMessage(messages.customFieldLabel) || - 'Label for field type select, translation obj or string', - component: MyCustomWidget, - isValid: (formData, name) => true, -}); -``` - -The widget component should have the following firm: - -```js -({ - id, - name, - title, - description, - required, - onChange, - value, - isDisabled, - invalid, -}) => ReactElement; -``` - -You should also pass a function to validate your field's data. -The `isValid` function accepts `formData` (the whole form data) and the name of the field, thus you can access to your fields' data as `formData[name]` but you also have access to other fields. - -`isValid` has the firm: - -```js -(formData, name) => boolean; -``` - -Example custom field [here](https://gist.github.com/nzambello/30949078616328e6ee0293e5b302bb40). - -## Static fields - -In backend integration, you can add in block data an object called `static_fields` and the form block will show those in form view as readonly and will aggregate those with user compiled data. - -i.e.: aggregated data from user federated authentication: - -![Static fields](./docs/form-static-fields.png) - -## Schema validators - -If you want to validate configuration field (for example, testing if 'From email' is an address of a specific domain), you could add your validation functions to block config: - -```js -config.blocks.blocksConfig.form = { - ...config.blocks.blocksConfig.form, - schemaValidators: { - fieldname: yourValidationFN(data), - }, -}; -``` - -`yourValidationFN` have to return: - -- null if field is valid -- a string with the error message if field is invalid. - -## Upgrade guide - -To upgrade to version 2.4.0 you need to: - -- remove the env vars -- install [https://github.com/plone/plone.formwidget.hcaptcha](https://github.com/plone/plone.formwidget.hcaptcha) or [https://github.com/plone/plone.formwidget.recaptcha](https://github.com/plone/plone.formwidget.recaptcha) or both in Plone. -- insert private and public keys in Plone HCaptcha controlpanel or/and Plone ReCaptcha controlpanel. - -## Video demos - -- [Form usage](https://youtu.be/v5KtjEACRmI) -- [Form editing](https://youtu.be/wmTpzYBtNCQ) -- [Export stored data](https://youtu.be/3zVUaGaaVOg) - -## VERSIONS - -With volto-form-block@2.5.0 you need to upgrade collective.volto.formsupport to version 2.4.0 - -## Installation - -To install your project, you must choose the method appropriate to your version of Volto. - - -### Volto 17 and earlier - -Create a new Volto project (you can skip this step if you already have one): - -``` -npm install -g yo @plone/generator-volto -yo @plone/volto my-volto-project --addon volto-form-block -cd my-volto-project -``` - -Add `volto-form-block` to your package.json: - -```JSON -"addons": [ - "volto-form-block" -], - -"dependencies": { - "volto-form-block": "*" -} -``` - -Download and install the new add-on by running: - -``` -yarn install -``` - -Start volto with: - -``` -yarn start -``` - -### Volto 18 and later - -Add `volto-form-block` to your add-on `package.json`: - -```json -"dependencies": { - "volto-form-block": "*" -} -``` - -## Test installation - -Visit http://localhost:3000/ in a browser, login, and check the awesome new features. - - -## Development - -The development of this add-on is done in isolation using a new approach using pnpm workspaces and latest `mrs-developer` and other Volto core improvements. -For this reason, it only works with pnpm and Volto 18 (currently in alpha). - - -### Pre-requisites - -- [Node.js](https://6.docs.plone.org/install/create-project.html#node-js) -- [Make](https://6.docs.plone.org/install/create-project.html#make) -- [Docker](https://6.docs.plone.org/install/create-project.html#docker) - - -### Make convenience commands - -Run `make help` to list the available commands. - -```text -help Show this help -install Installs the add-on in a development environment -start Starts Volto, allowing reloading of the add-on during development -build Build a production bundle for distribution of the project with the add-on -i18n Sync i18n -ci-i18n Check if i18n is not synced -format Format codebase -lint Lint, or catch and remove problems, in code base -release Release the add-on on npmjs.org -release-dry-run Dry-run the release of the add-on on npmjs.org -test Run unit tests -ci-test Run unit tests in CI -backend-docker-start Starts a Docker-based backend for development -storybook-start Start Storybook server on port 6006 -storybook-build Build Storybook -acceptance-frontend-dev-start Start acceptance frontend in development mode -acceptance-frontend-prod-start Start acceptance frontend in production mode -acceptance-backend-start Start backend acceptance server -ci-acceptance-backend-start Start backend acceptance server in headless mode for CI -acceptance-test Start Cypress in interactive mode -ci-acceptance-test Run cypress tests in headless mode for CI +```shell +git clone git@github.com:collective/volto-form-block.git +cd volto-form-block ``` -### Development environment set up - -Install package requirements. +2. Install both Backend and Frontend: ```shell make install ``` -### Start developing +### Fire Up the Servers 🔥 -Start the backend. +1. Create a new Plone site on your first run: ```shell -make backend-docker-start +make backend-create-site ``` -In a separate terminal session, start the frontend. +2. Start the Backend at [http://localhost:8080/](http://localhost:8080/): ```shell -make start +make backend-start ``` -### Lint code - -Run ESlint, Prettier, and Stylelint in analyze mode. +3. In a new terminal, start the Frontend at [http://localhost:3000/](http://localhost:3000/): ```shell -make lint +make frontend-start ``` -### Format code +Voila! Your Plone site should be live and kicking! 🎉 -Run ESlint, Prettier, and Stylelint in fix mode. +### Local Stack Deployment 📦 -```shell -make format -``` +Deploy a local `Docker Compose` environment that includes: -### i18n +- Docker images for Backend and Frontend 🖼️ +- A stack with a Traefik router and a Postgres database 🗃️ +- Accessible at [http://volto-form-block.localhost](http://volto-form-block.localhost) 🌐 -Extract the i18n messages to locales. +Execute the following: ```shell -make i18n +make stack-start +make stack-create-site ``` -### Unit tests +And... you're all set! Your Plone site is up and running locally! 🚀 -Run unit tests. +## Project Structure 🏗️ -```shell -make test -``` +This monorepo consists of three distinct sections: `backend`, `frontend`, and `devops`. -### Run Cypress tests +- **backend**: Houses the API and Plone installation, utilizing pip instead of buildout, and includes a policy package named collective.voltoformblock. +- **frontend**: Contains the React (Volto) package. +- **devops**: Encompasses Docker Stack, Ansible playbooks, and Cache settings. -Run each of these steps in separate terminal sessions. +### Why This Structure? 🤔 -In the first session, start the frontend in development mode. +- All necessary codebases to run the site are contained within the repo (excluding existing addons for Plone and React). +- Specific GitHub Workflows are triggered based on changes in each codebase (refer to .github/workflows). +- Simplifies the creation of Docker images for each codebase. +- Demonstrates Plone installation/setup without buildout. -```shell -make acceptance-frontend-dev-start -``` +## Code Quality Assurance 🧐 -In the second session, start the backend acceptance server. +To automatically format your code and ensure it adheres to quality standards, execute: ```shell -make acceptance-backend-start +make check ``` -In the third session, start the Cypress interactive test runner. +Linters can be run individually within the `backend` or `frontend` folders. -```shell -make acceptance-test -``` +## Internationalization 🌐 -## License +Generate translation files for Plone and Volto with ease: -The project is licensed under the MIT license. +```shell +make i18n +``` ## Credits and Acknowledgements 🙏 -Crafted with care by **Generated using [Cookieplone (0.7.1)](https://github.com/plone/cookieplone) and [cookiecutter-plone (aee0d59)](https://github.com/plone/cookiecutter-plone/commit/aee0d59c18bd0dd8af1da9c961014ff87a66ccfa) on 2024-07-04 10:49:50.444730**. A special thanks to all contributors and supporters! +Crafted with care by **Generated using [Cookieplone (0.7.1)](https://github.com/plone/cookieplone) and [cookiecutter-plone (aee0d59)](https://github.com/plone/cookiecutter-plone/commit/aee0d59c18bd0dd8af1da9c961014ff87a66ccfa) on 2024-07-08 12:03:37.289610**. A special thanks to all contributors and supporters! diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..e9e94ff --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,14 @@ +.editorconfig +.gitattributes +bin +Dockerfile +Dockerfile.acceptance +include +instance +instance.yaml +lib +lib64 +Makefile +pyvenv.cfg +var +.venv diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..8ae05aa --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,54 @@ +# Generated from: +# https://github.com/plone/meta/tree/master/config/default +# See the inline comments on how to expand/tweak this configuration file +# +# EditorConfig Configuration file, for more details see: +# http://EditorConfig.org +# EditorConfig is a convention description, that could be interpreted +# by multiple editors to enforce common coding conventions for specific +# file types + +# top-most EditorConfig file: +# Will ignore other EditorConfig files in Home directory or upper tree level. +root = true + + +[*] # For All Files +# Unix-style newlines with a newline ending every file +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +# Set default charset +charset = utf-8 +# Indent style default +indent_style = space +# Max Line Length - a hard line wrap, should be disabled +max_line_length = off + +[*.{py,cfg,ini}] +# 4 space indentation +indent_size = 4 + +[*.{yml,zpt,pt,dtml,zcml}] +# 2 space indentation +indent_size = 2 + +[*.{json,jsonl,js,jsx,ts,tsx,css,less,scss,html}] # Frontend development +# 2 space indentation +indent_size = 2 +max_line_length = 80 + +[{Makefile,.gitmodules}] +# Tab indentation (no size specified, but view as 4 spaces) +indent_style = tab +indent_size = unset +tab_width = unset + + +## +# Add extra configuration options in .meta.toml: +# [editorconfig] +# extra_lines = """ +# _your own configuration lines_ +# """ +## diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 0000000..7ef4f64 --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,22 @@ +# Generated from: +# https://github.com/plone/meta/tree/master/config/default +# See the inline comments on how to expand/tweak this configuration file +[flake8] +doctests = 1 +ignore = + # black takes care of line length + E501, + # black takes care of where to break lines + W503, + # black takes care of spaces within slicing (list[:]) + E203, + # black takes care of spaces after commas + E231, + +## +# Add extra configuration options in .meta.toml: +# [flake8] +# extra_lines = """ +# _your own configuration lines_ +# """ +## diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..4f64665 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,52 @@ +# Generated from: +# https://github.com/plone/meta/tree/master/config/default +# See the inline comments on how to expand/tweak this configuration file +# python related +*.egg-info +*.pyc +*.pyo + +# tools related +build/ +.coverage +coverage.xml +dist/ +docs/_build +__pycache__/ +.tox +.vscode/ +node_modules/ + +# venv / buildout related +bin/ +develop-eggs/ +eggs/ +.eggs/ +etc/ +.installed.cfg +include/ +lib/ +lib64 +.mr.developer.cfg +parts/ +pyvenv.cfg +var/ +*.mo + +# mxdev +/instance/ +/.make-sentinels/ +/*-mxdev.txt +/reports/ +/sources/ +/venv/ +.installed.txt +.lock + +## +# Add extra configuration options in .meta.toml: +# [gitignore] +# extra_lines = """ +# _your own configuration lines_ +# """ +## diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml new file mode 100644 index 0000000..b6eb043 --- /dev/null +++ b/backend/.pre-commit-config.yaml @@ -0,0 +1,94 @@ +# Generated from: +# https://github.com/plone/meta/tree/master/config/default +# See the inline comments on how to expand/tweak this configuration file +ci: + autofix_prs: false + autoupdate_schedule: monthly + +repos: +- repo: https://github.com/asottile/pyupgrade + rev: v3.14.0 + hooks: + - id: pyupgrade + args: [--py38-plus] +- repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort +- repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black +- repo: https://github.com/collective/zpretty + rev: 3.1.0 + hooks: + - id: zpretty + +## +# Add extra configuration options in .meta.toml: +# [pre_commit] +# zpretty_extra_lines = """ +# _your own configuration lines_ +# """ +## +- repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + +## +# Add extra configuration options in .meta.toml: +# [pre_commit] +# flake8_extra_lines = """ +# _your own configuration lines_ +# """ +## +- repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + additional_dependencies: + - tomli + +## +# Add extra configuration options in .meta.toml: +# [pre_commit] +# codespell_extra_lines = """ +# _your own configuration lines_ +# """ +## +- repo: https://github.com/mgedmin/check-manifest + rev: "0.49" + hooks: + - id: check-manifest +- repo: https://github.com/regebro/pyroma + rev: "4.2" + hooks: + - id: pyroma +- repo: https://github.com/mgedmin/check-python-versions + rev: "0.21.3" + hooks: + - id: check-python-versions + args: ['--only', 'setup.py,pyproject.toml'] +- repo: https://github.com/collective/i18ndude + rev: "6.1.0" + hooks: + - id: i18ndude + + +## +# Add extra configuration options in .meta.toml: +# [pre_commit] +# i18ndude_extra_lines = """ +# _your own configuration lines_ +# """ +## + + +## +# Add extra configuration options in .meta.toml: +# [pre_commit] +# extra_lines = """ +# _your own configuration lines_ +# """ +## diff --git a/backend/CHANGES.md b/backend/CHANGES.md new file mode 100644 index 0000000..3020bae --- /dev/null +++ b/backend/CHANGES.md @@ -0,0 +1,10 @@ +# Changelog + + + + diff --git a/backend/CONTRIBUTORS.md b/backend/CONTRIBUTORS.md new file mode 100644 index 0000000..5a1aabd --- /dev/null +++ b/backend/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +# Contributors + +- Plone Foundation [collective@plone.org] diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..180c48a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 +ARG PLONE_VERSION=6.0.11 +FROM plone/server-builder:${PLONE_VERSION} as builder + +WORKDIR /app + + +# Add local code +COPY scripts/ scripts/ +COPY . src + +# Install local requirements and pre-compile mo files +RUN < + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/backend/LICENSE.md b/backend/LICENSE.md new file mode 100644 index 0000000..8088e56 --- /dev/null +++ b/backend/LICENSE.md @@ -0,0 +1,15 @@ +collective.voltoformblock Copyright 2023, Plone Foundation + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 2 +as published by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, +MA 02111-1307 USA. diff --git a/backend/MANIFEST.in b/backend/MANIFEST.in new file mode 100644 index 0000000..6c0bcab --- /dev/null +++ b/backend/MANIFEST.in @@ -0,0 +1,20 @@ +graft src/collective +graft docs +graft news +graft tests +graft scripts +include *.acceptance +include .coveragerc +include .dockerignore +include .editorconfig +include *.txt +include *.yml +include *.md +exclude *-mxdev.txt +exclude Dockerfile +exclude mx.ini +exclude Makefile +recursive-exclude frontend * +exclude instance.yaml +global-exclude *.pyc +global-exclude .DS_Store diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..be12a1e --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,139 @@ +### Defensive settings for make: +# https://tech.davis-hansson.com/p/make/ +SHELL:=bash +.ONESHELL: +.SHELLFLAGS:=-xeu -o pipefail -O inherit_errexit -c +.SILENT: +.DELETE_ON_ERROR: +MAKEFLAGS+=--warn-undefined-variables +MAKEFLAGS+=--no-builtin-rules + +# We like colors +# From: https://coderwall.com/p/izxssa/colored-makefile-for-golang-projects +RED=`tput setaf 1` +GREEN=`tput setaf 2` +RESET=`tput sgr0` +YELLOW=`tput setaf 3` + +IMAGE_NAME_PREFIX=ghcr.io/collective/volto-form-block +IMAGE_TAG=latest + +# Python checks +PYTHON?=python3 + +# installed? +ifeq (, $(shell which $(PYTHON) )) + $(error "PYTHON=$(PYTHON) not found in $(PATH)") +endif + +# version ok? +PYTHON_VERSION_MIN=3.8 +PYTHON_VERSION_OK=$(shell $(PYTHON) -c "import sys; print((int(sys.version_info[0]), int(sys.version_info[1])) >= tuple(map(int, '$(PYTHON_VERSION_MIN)'.split('.'))))") +ifeq ($(PYTHON_VERSION_OK),0) + $(error "Need python $(PYTHON_VERSION) >= $(PYTHON_VERSION_MIN)") +endif + +PLONE_SITE_ID=Plone +BACKEND_FOLDER=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +PLONE_VERSION=$(shell cat $(BACKEND_FOLDER)/version.txt) +EXAMPLE_CONTENT_FOLDER=${BACKEND_FOLDER}/src/collective/voltoformblock/setuphandlers/examplecontent + +GIT_FOLDER=$(BACKEND_FOLDER)/.git +VENV_FOLDER=$(BACKEND_FOLDER)/.venv +BIN_FOLDER=$(VENV_FOLDER)/bin + + +all: build + +# Add the following 'help' target to your Makefile +# And add help text after each target name starting with '\#\#' +.PHONY: help +help: ## This help message + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +$(BIN_FOLDER)/pip $(BIN_FOLDER)/tox $(BIN_FOLDER)/pipx $(BIN_FOLDER)/uv $(BIN_FOLDER)/mxdev: + @echo "$(GREEN)==> Setup Virtual Env$(RESET)" + $(PYTHON) -m venv $(VENV_FOLDER) + $(BIN_FOLDER)/pip install -U "pip" "uv" "wheel" "pipx" "mxdev" "tox" "pre-commit" + if [ -d $(GIT_FOLDER) ]; then $(BIN_FOLDER)/pre-commit install; else echo "$(RED) Not installing pre-commit$(RESET)";fi + +instance/etc/zope.ini: $(BIN_FOLDER)/pip ## Create instance configuration + @echo "$(GREEN)==> Create instance configuration$(RESET)" + $(BIN_FOLDER)/pipx run cookiecutter -f --no-input --config-file instance.yaml gh:plone/cookiecutter-zope-instance + +.PHONY: config +config: instance/etc/zope.ini + +.PHONY: build-dev +build-dev: config ## Install Plone packages + @echo "$(GREEN)==> Setup Build$(RESET)" + $(BIN_FOLDER)/mxdev -c mx.ini + $(BIN_FOLDER)/uv pip install -r requirements-mxdev.txt + +.PHONY: install +install: build-dev ## Install Plone + +.PHONY: build +build: build-dev ## Install Plone + +.PHONY: clean +clean: ## Clean environment + @echo "$(RED)==> Cleaning environment and build$(RESET)" + rm -rf $(VENV_FOLDER) pyvenv.cfg .installed.cfg instance .tox .venv .pytest_cache + +.PHONY: start +start: ## Start a Plone instance on localhost:8080 + PYTHONWARNINGS=ignore $(BIN_FOLDER)/runwsgi instance/etc/zope.ini + +.PHONY: console +console: instance/etc/zope.ini ## Start a console into a Plone instance + PYTHONWARNINGS=ignore $(BIN_FOLDER)/zconsole debug instance/etc/zope.conf + +.PHONY: create-site +create-site: instance/etc/zope.ini ## Create a new site from scratch + PYTHONWARNINGS=ignore $(BIN_FOLDER)/zconsole run instance/etc/zope.conf ./scripts/create_site.py + +# Example Content +.PHONY: update-example-content +update-example-content: $(BIN_FOLDER)/tox ## Export example content inside package + @echo "$(GREEN)==> Export example content into $(EXAMPLE_CONTENT_FOLDER) $(RESET)" + if [ -d $(EXAMPLE_CONTENT_FOLDER)/content ]; then rm -r $(EXAMPLE_CONTENT_FOLDER)/* ;fi + $(BIN_FOLDER)/plone-exporter instance/etc/zope.conf $(PLONE_SITE_ID) $(EXAMPLE_CONTENT_FOLDER) + +.PHONY: check +check: $(BIN_FOLDER)/tox ## Check and fix code base according to Plone standards + @echo "$(GREEN)==> Format codebase$(RESET)" + $(BIN_FOLDER)/tox -e lint + +# i18n +$(BIN_FOLDER)/i18ndude: $(BIN_FOLDER)/pip + @echo "$(GREEN)==> Install translation tools$(RESET)" + $(BIN_FOLDER)/uv pip install i18ndude + +.PHONY: i18n +i18n: $(BIN_FOLDER)/i18ndude ## Update locales + @echo "$(GREEN)==> Updating locales$(RESET)" + $(BIN_FOLDER)/update_locale + +# Tests +.PHONY: test +test: $(BIN_FOLDER)/tox ## run tests + $(BIN_FOLDER)/tox -e test + +.PHONY: test-coverage +test-coverage: $(BIN_FOLDER)/tox ## run tests with coverage + $(BIN_FOLDER)/tox -e coverage + +# Build Docker images +.PHONY: build-image +build-image: ## Build Docker Images + @DOCKER_BUILDKIT=1 docker build . -t $(IMAGE_NAME_PREFIX)-backend:$(IMAGE_TAG) -f Dockerfile --build-arg PLONE_VERSION=$(PLONE_VERSION) + +# Acceptance tests +.PHONY: acceptance-backend-start +acceptance-backend-start: ## Start backend acceptance server + ZSERVER_HOST=0.0.0.0 ZSERVER_PORT=55001 LISTEN_PORT=55001 APPLY_PROFILES="collective/voltoformblock:default" CONFIGURE_PACKAGES="plone.restapi,plone.volto,plone.volto.cors,collective/voltoformblock" $(BIN_FOLDER)/robot-server plone.app.robotframework.testing.VOLTO_ROBOT_TESTING + +.PHONY: acceptance-image-build +acceptance-image-build: ## Build Docker Images + @DOCKER_BUILDKIT=1 docker build . -t $(IMAGE_NAME_PREFIX)-backend-acceptance:$(IMAGE_TAG) -f Dockerfile.acceptance --build-arg PLONE_VERSION=$(PLONE_VERSION) diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..3a5b177 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,33 @@ +# collective.voltoformblock + +A new project using Plone 6. + +## Features + +TODO: List our awesome features + +## Installation + +Install collective.voltoformblock with `pip`: + +```shell +pip install collective.voltoformblock +``` +And to create the Plone site: + +```shell +make create_site +``` + +## Contribute + +- [Issue Tracker](https://github.com/collective/collective.voltoformblock/issues) +- [Source Code](https://github.com/collective/collective.voltoformblock/) + +## License + +The project is licensed under GPLv2. + +## Credits and Acknowledgements 🙏 + +Crafted with care by **This was generated by [cookiecutter-plone](https://github.com/plone/cookieplone-templates/backend_addon) on 2024-07-08 10:04:24**. A special thanks to all contributors and supporters! diff --git a/backend/constraints.txt b/backend/constraints.txt new file mode 100644 index 0000000..d32a83f --- /dev/null +++ b/backend/constraints.txt @@ -0,0 +1 @@ +-c https://dist.plone.org/release/6.0.11/constraints.txt diff --git a/cypress/.gitkeep b/backend/docs/.gitkeep similarity index 100% rename from cypress/.gitkeep rename to backend/docs/.gitkeep diff --git a/backend/instance.yaml b/backend/instance.yaml new file mode 100644 index 0000000..b9d3b29 --- /dev/null +++ b/backend/instance.yaml @@ -0,0 +1,3 @@ +default_context: + initial_user_password: 'admin' + zcml_package_includes: 'collective.voltoformblock' \ No newline at end of file diff --git a/backend/mx.ini b/backend/mx.ini new file mode 100644 index 0000000..9fccb99 --- /dev/null +++ b/backend/mx.ini @@ -0,0 +1,17 @@ +; This is a mxdev configuration file +; it can be used to override versions of packages already defined in the +; constraints files and to add new packages from VCS like git. +; to learn more about mxdev visit https://pypi.org/project/mxdev/ + +[settings] +main-package = -e .[test] +; example how to override a package version +; version-overrides = +; example.package==2.1.0a2 + +; example section to use packages from git +; [example.contenttype] +; url = https://github.com/collective/example.contenttype.git +; pushurl = git@github.com:collective/example.contenttype.git +; extras = test +; branch = feature-7 diff --git a/backend/news/.changelog_template.jinja b/backend/news/.changelog_template.jinja new file mode 100644 index 0000000..678bfa1 --- /dev/null +++ b/backend/news/.changelog_template.jinja @@ -0,0 +1,15 @@ +{% if sections[] %} +{% for category, val in definitions.items() if category in sections[] %} + +### {{ definitions[category]['name'] }} + +{% for text, values in sections[][category].items() %} +- {{ text }} {{ values|join(', ') }} +{% endfor %} + +{% endfor %} +{% else %} +No significant changes. + + +{% endif %} diff --git a/backend/news/.gitkeep b/backend/news/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/news/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..c982aa9 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,170 @@ +# Generated from: +# https://github.com/plone/meta/tree/main/config/default +# See the inline comments on how to expand/tweak this configuration file +[build-system] +requires = ["setuptools>=68.2"] + +[tool.towncrier] +directory = "news/" +filename = "CHANGES.md" +start_string = "\n" +title_format = "## {version} ({project_date})" +template = "news/.changelog_template.jinja" +underlines = ["", "", ""] + +[[tool.towncrier.type]] +directory = "breaking" +name = "Breaking changes:" +showcontent = true + +[[tool.towncrier.type]] +directory = "feature" +name = "New features:" +showcontent = true + +[[tool.towncrier.type]] +directory = "bugfix" +name = "Bug fixes:" +showcontent = true + +[[tool.towncrier.type]] +directory = "internal" +name = "Internal:" +showcontent = true + +[[tool.towncrier.type]] +directory = "documentation" +name = "Documentation:" +showcontent = true + +[[tool.towncrier.type]] +directory = "tests" +name = "Tests" +showcontent = true + +## +# Add extra configuration options in .meta.toml: +# [pyproject] +# towncrier_extra_lines = """ +# extra_configuration +# """ +## + +[tool.isort] +profile = "plone" + +## +# Add extra configuration options in .meta.toml: +# [pyproject] +# isort_extra_lines = """ +# extra_configuration +# """ +## + +[tool.black] +target-version = ["py38"] + +## +# Add extra configuration options in .meta.toml: +# [pyproject] +# black_extra_lines = """ +# extra_configuration +# """ +## + +[tool.codespell] +ignore-words-list = "discreet,vew" +skip = "*.po,*.min.js" +## +# Add extra configuration options in .meta.toml: +# [pyproject] +# codespell_ignores = "foo,bar" +# codespell_skip = "*.po,*.map,package-lock.json" +## + +[tool.dependencychecker] +Zope = [ + # Zope own provided namespaces + 'App', 'OFS', 'Products.Five', 'Products.OFSP', 'Products.PageTemplates', + 'Products.SiteAccess', 'Shared', 'Testing', 'ZPublisher', 'ZTUtils', + 'Zope2', 'webdav', 'zmi', + # ExtensionClass own provided namespaces + 'ExtensionClass', 'ComputedAttribute', 'MethodObject', + # Zope dependencies + 'AccessControl', 'Acquisition', 'AuthEncoding', 'beautifulsoup4', 'BTrees', + 'cffi', 'Chameleon', 'DateTime', 'DocumentTemplate', + 'MultiMapping', 'multipart', 'PasteDeploy', 'Persistence', 'persistent', + 'pycparser', 'python-gettext', 'pytz', 'RestrictedPython', 'roman', + 'soupsieve', 'transaction', 'waitress', 'WebOb', 'WebTest', 'WSGIProxy2', + 'z3c.pt', 'zc.lockfile', 'ZConfig', 'zExceptions', 'ZODB', 'zodbpickle', + 'zope.annotation', 'zope.browser', 'zope.browsermenu', 'zope.browserpage', + 'zope.browserresource', 'zope.cachedescriptors', 'zope.component', + 'zope.configuration', 'zope.container', 'zope.contentprovider', + 'zope.contenttype', 'zope.datetime', 'zope.deferredimport', + 'zope.deprecation', 'zope.dottedname', 'zope.event', 'zope.exceptions', + 'zope.filerepresentation', 'zope.globalrequest', 'zope.hookable', + 'zope.i18n', 'zope.i18nmessageid', 'zope.interface', 'zope.lifecycleevent', + 'zope.location', 'zope.pagetemplate', 'zope.processlifetime', 'zope.proxy', + 'zope.ptresource', 'zope.publisher', 'zope.schema', 'zope.security', + 'zope.sequencesort', 'zope.site', 'zope.size', 'zope.structuredtext', + 'zope.tal', 'zope.tales', 'zope.testbrowser', 'zope.testing', + 'zope.traversing', 'zope.viewlet' +] +'Products.CMFCore' = [ + 'docutils', 'five.localsitemanager', 'Missing', 'Products.BTreeFolder2', + 'Products.GenericSetup', 'Products.MailHost', 'Products.PythonScripts', + 'Products.StandardCacheManagers', 'Products.ZCatalog', 'Record', + 'zope.sendmail', 'Zope' +] +'plone.base' = [ + 'plone.batching', 'plone.registry', 'plone.schema','plone.z3cform', + 'Products.CMFCore', 'Products.CMFDynamicViewFTI', +] +python-dateutil = ['dateutil'] +pytest-plone = ['pytest', 'plone.testing', 'plone.app.testing'] +ignore-packages = ['plone.app.iterate', 'plone.app.upgrade', 'plone.volto', 'zestreleaser.towncrier', 'zest.releaser', 'pytest-cov'] + +## +# Add extra configuration options in .meta.toml: +# [pyproject] +# dependencies_ignores = "['zestreleaser.towncrier']" +# dependencies_mappings = [ +# "gitpython = ['git']", +# "pygithub = ['github']", +# ] +## + +[tool.check-manifest] +ignore = [ + ".editorconfig", + ".flake8", + ".meta.toml", + ".pre-commit-config.yaml", + "dependabot.yml", + "mx.ini", + "tox.ini", + +] + +## +# Add extra configuration options in .meta.toml: +# [pyproject] +# check_manifest_ignores = """ +# "*.map.js", +# "*.pyc", +# """ +# check_manifest_extra_lines = """ +# ignore-bad-ideas = [ +# "some/test/file/PKG-INFO", +# ] +# """ +## + + +## +# Add extra configuration options in .meta.toml: +# [pyproject] +# extra_lines = """ +# _your own configuration lines_ +# """ +## diff --git a/backend/requirements-docker.txt b/backend/requirements-docker.txt new file mode 100644 index 0000000..da44320 --- /dev/null +++ b/backend/requirements-docker.txt @@ -0,0 +1 @@ +-c constraints.txt diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..da44320 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1 @@ +-c constraints.txt diff --git a/backend/scripts/create_site.py b/backend/scripts/create_site.py new file mode 100644 index 0000000..4a4a703 --- /dev/null +++ b/backend/scripts/create_site.py @@ -0,0 +1,72 @@ +from AccessControl.SecurityManagement import newSecurityManager +from collective.volto.formsupport.interfaces import ( # noqa + ICollectiveVoltoFormsupportLayer, +) +from Products.CMFPlone.factory import _DEFAULT_PROFILE +from Products.CMFPlone.factory import addPloneSite +from Products.GenericSetup.tool import SetupTool +from Testing.makerequest import makerequest +from zope.interface import directlyProvidedBy +from zope.interface import directlyProvides + +import os +import transaction + + +truthy = frozenset(("t", "true", "y", "yes", "on", "1")) + + +def asbool(s): + """Return the boolean value ``True`` if the case-lowered value of string + input ``s`` is a :term:`truthy string`. If ``s`` is already one of the + boolean values ``True`` or ``False``, return it.""" + if s is None: + return False + if isinstance(s, bool): + return s + s = str(s).strip() + return s.lower() in truthy + + +DELETE_EXISTING = asbool(os.getenv("DELETE_EXISTING")) +EXAMPLE_CONTENT = asbool( + os.getenv("EXAMPLE_CONTENT", "1") +) # Create example content by default + +app = makerequest(globals()["app"]) + +request = app.REQUEST + +ifaces = [ICollectiveVoltoFormsupportLayer] + list(directlyProvidedBy(request)) + +directlyProvides(request, *ifaces) + +admin = app.acl_users.getUserById("admin") +admin = admin.__of__(app.acl_users) +newSecurityManager(None, admin) + +site_id = "Plone" +payload = { + "title": "Project Title", + "profile_id": _DEFAULT_PROFILE, + "extension_ids": [ + "collective.voltoformblock:default", + ], + "setup_content": False, + "default_language": "en", + "portal_timezone": "UTC", +} + +if site_id in app.objectIds() and DELETE_EXISTING: + app.manage_delObjects([site_id]) + transaction.commit() + app._p_jar.sync() + +if site_id not in app.objectIds(): + site = addPloneSite(app, site_id, **payload) + transaction.commit() + if EXAMPLE_CONTENT: + portal_setup: SetupTool = site.portal_setup + portal_setup.runAllImportStepsFromProfile("collective.voltoformblock:initial") + transaction.commit() + app._p_jar.sync() diff --git a/backend/setup.py b/backend/setup.py new file mode 100644 index 0000000..dee56e3 --- /dev/null +++ b/backend/setup.py @@ -0,0 +1,113 @@ +"""Installer for the collective.voltoformblock package.""" + +from pathlib import Path +from setuptools import find_packages +from setuptools import setup + + +long_description = f""" +{Path("README.md").read_text()}\n +{Path("CONTRIBUTORS.md").read_text()}\n +{Path("CHANGES.md").read_text()}\n +""" + + +setup( + name="collective.volto.formsupport", + version="3.1.1.dev0", + description="Add support for customizable forms in Volto", + long_description=long_description, + long_description_content_type="text/markdown", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Plone", + "Framework :: Plone :: Addon", + "Framework :: Plone :: 5.2", + "Framework :: Plone :: 6.0", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Operating System :: OS Independent", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", + ], + keywords="Python Plone CMS", + author="RedTurtle Technology", + author_email="sviluppo@redturtle.it", + url="https://github.com/collective/volto-form-block", + project_urls={ + "PyPI": "https://pypi.org/project/volto-form-block", + "Source": "https://github.com/collective/volto-form-block", + "Tracker": "https://github.com/collective/volto-form-block/issues", + }, + license="GPL version 2", + packages=find_packages("src", exclude=["ez_setup"]), + namespace_packages=["collective", "collective.volto"], + package_dir={"": "src"}, + include_package_data=True, + zip_safe=False, + python_requires=">=3.8", + install_requires=[ + "setuptools", + "z3c.jbot", + "Zope", + "plone.api>=1.8.4", + "plone.dexterity", + "plone.keyring", + "plone.i18n", + "plone.memoize", + "plone.protect", + "plone.registry", + "plone.restapi>=8.36.0", + "plone.schema", + "Products.GenericSetup", + "Products.PortalTransforms", + "souper.plone", + "click", + "beautifulsoup4", + "pyotp", + ], + extras_require={ + "test": [ + "zest.releaser[recommended]", + "zestreleaser.towncrier", + "plone.app.testing", + "plone.restapi[test]", + "pytest", + "pytest-cov", + "pytest-plone>=0.5.0", + "Products.MailHost", + "plone.browserlayer", + "collective.MockMailHost", + "collective.honeypot", + "plone.formwidget.hcaptcha", + "plone.formwidget.recaptcha", + "collective.z3cform.norobots", + "collective.honeypot", + ], + "hcaptcha": [ + "plone.formwidget.hcaptcha>=1.0.1", + ], + "recaptcha": [ + "plone.formwidget.recaptcha", + ], + "norobots": [ + "collective.z3cform.norobots", + ], + "honeypot": [ + "collective.honeypot>=2.1", + ], + "blocksfield": [ + "collective.volto.blocksfield", + ], + }, + entry_points=""" + [z3c.autoinclude.plugin] + target = plone + [console_scripts] + update_locale = collective.voltoformblock.locales.update:update_locale + formsupport_data_cleansing = collective.volto.formsupport.scripts.cleansing:main + """, +) diff --git a/backend/src/collective/__init__.py b/backend/src/collective/__init__.py new file mode 100644 index 0000000..5284146 --- /dev/null +++ b/backend/src/collective/__init__.py @@ -0,0 +1 @@ +__import__("pkg_resources").declare_namespace(__name__) diff --git a/backend/src/collective/volto/__init__.py b/backend/src/collective/volto/__init__.py new file mode 100644 index 0000000..5284146 --- /dev/null +++ b/backend/src/collective/volto/__init__.py @@ -0,0 +1 @@ +__import__("pkg_resources").declare_namespace(__name__) diff --git a/backend/src/collective/volto/formsupport/__init__.py b/backend/src/collective/volto/formsupport/__init__.py new file mode 100644 index 0000000..9360714 --- /dev/null +++ b/backend/src/collective/volto/formsupport/__init__.py @@ -0,0 +1,9 @@ +"""Init and utils.""" + +from zope.i18nmessageid import MessageFactory + +import logging + + +logger = logging.getLogger(__name__) +_ = MessageFactory("collective.volto.formsupport") diff --git a/cypress/tests/.gitkeep b/backend/src/collective/volto/formsupport/browser/__init__.py similarity index 100% rename from cypress/tests/.gitkeep rename to backend/src/collective/volto/formsupport/browser/__init__.py diff --git a/backend/src/collective/volto/formsupport/browser/configure.zcml b/backend/src/collective/volto/formsupport/browser/configure.zcml new file mode 100644 index 0000000..4c2dff8 --- /dev/null +++ b/backend/src/collective/volto/formsupport/browser/configure.zcml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/browser/email_confirm_view.py b/backend/src/collective/volto/formsupport/browser/email_confirm_view.py new file mode 100644 index 0000000..373e571 --- /dev/null +++ b/backend/src/collective/volto/formsupport/browser/email_confirm_view.py @@ -0,0 +1,18 @@ +from plone import api +from Products.Five.browser import BrowserView + + +class EmailConfirmView(BrowserView): + def __call__(self, token="alksdjfakls", *args, **kwargs): + self.token = token + + return super().__call__(*args, **kwargs) + + def get_token(self): + return self.token + + def get_portal(self): + return api.portal.get() + + def context_url(self): + return self.context.absolute_url() diff --git a/packages/volto-form-block/news/.gitkeep b/backend/src/collective/volto/formsupport/browser/overrides/.gitkeep similarity index 100% rename from packages/volto-form-block/news/.gitkeep rename to backend/src/collective/volto/formsupport/browser/overrides/.gitkeep diff --git a/backend/src/collective/volto/formsupport/browser/send_mail_template.pt b/backend/src/collective/volto/formsupport/browser/send_mail_template.pt new file mode 100644 index 0000000..1d17eff --- /dev/null +++ b/backend/src/collective/volto/formsupport/browser/send_mail_template.pt @@ -0,0 +1,38 @@ + +
+ +
+
    + +
  • + label: + ${value} +
  • +
    +
+
+ + +

+ A new form has been submitted from + url +

+
+
+
diff --git a/backend/src/collective/volto/formsupport/browser/send_mail_template_table.pt b/backend/src/collective/volto/formsupport/browser/send_mail_template_table.pt new file mode 100644 index 0000000..fc5fd2b --- /dev/null +++ b/backend/src/collective/volto/formsupport/browser/send_mail_template_table.pt @@ -0,0 +1,57 @@ + + +
+ + + Form submission data for ${title} + +
+ + + + + + + + + + + + + + + +
FieldValue
${label}${value}
+
+ +
+
diff --git a/packages/volto-form-block/public/.gitkeep b/backend/src/collective/volto/formsupport/browser/static/.gitkeep similarity index 100% rename from packages/volto-form-block/public/.gitkeep rename to backend/src/collective/volto/formsupport/browser/static/.gitkeep diff --git a/backend/src/collective/volto/formsupport/browser/templates/email_confirm_view.pt b/backend/src/collective/volto/formsupport/browser/templates/email_confirm_view.pt new file mode 100644 index 0000000..bf08b63 --- /dev/null +++ b/backend/src/collective/volto/formsupport/browser/templates/email_confirm_view.pt @@ -0,0 +1,407 @@ + + + + + + Simple Transactional Email + + + + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/captcha/__init__.py b/backend/src/collective/volto/formsupport/captcha/__init__.py new file mode 100644 index 0000000..cfb766f --- /dev/null +++ b/backend/src/collective/volto/formsupport/captcha/__init__.py @@ -0,0 +1,13 @@ +class CaptchaSupport: + def __init__(self, context, request): + self.context = context + self.request = request + + def isEnabled(self): + return True + + def verify(self): + """ + Verify the captcha + """ + raise NotImplementedError diff --git a/backend/src/collective/volto/formsupport/captcha/configure.zcml b/backend/src/collective/volto/formsupport/captcha/configure.zcml new file mode 100644 index 0000000..194b1e2 --- /dev/null +++ b/backend/src/collective/volto/formsupport/captcha/configure.zcml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/captcha/hcaptcha.py b/backend/src/collective/volto/formsupport/captcha/hcaptcha.py new file mode 100644 index 0000000..3d56271 --- /dev/null +++ b/backend/src/collective/volto/formsupport/captcha/hcaptcha.py @@ -0,0 +1,63 @@ +from . import CaptchaSupport +from collective.volto.formsupport import _ +from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings +from plone.formwidget.hcaptcha.nohcaptcha import submit + +# from plone.formwidget.hcaptcha.validator import WrongCaptchaCode +from plone.registry.interfaces import IRegistry +from zExceptions import BadRequest +from zope.component import queryUtility +from zope.i18n import translate + + +class HCaptchaSupport(CaptchaSupport): + name = _("HCaptcha") + + def __init__(self, context, request): + super().__init__(context, request) + registry = queryUtility(IRegistry) + self.settings = registry.forInterface(IHCaptchaSettings, check=False) + + def isEnabled(self): + return self.settings and self.settings.public_key and self.settings.private_key + + def serialize(self): + if not self.settings.public_key: + raise ValueError( + "No hcaptcha public key configured. Go to " + "path/to/site/@@hcaptcha-settings to configure." + ) + return { + "provider": "hcaptcha", + "public_key": self.settings.public_key, + } + + def verify(self, data): + if not self.settings.private_key: + raise ValueError( + "No hcaptcha private key configured. Go to " + "path/to/site/@@hcaptcha-settings to configure." + ) + if not data or not data.get("token"): + raise BadRequest( + translate( + _("No captcha token provided."), + context=self.request, + ) + ) + token = data["token"] + remote_addr = self.request.get("HTTP_X_FORWARDED_FOR", "").split(",")[0] + if not remote_addr: + remote_addr = self.request.get("REMOTE_ADDR") + res = submit(token, self.settings.private_key, remote_addr) + if not res.is_valid: + raise BadRequest( + translate( + _("The code you entered was wrong, please enter the new one."), + context=self.request, + ) + ) + + +class HCaptchaInvisibleSupport(HCaptchaSupport): + name = _("HCaptcha Invisible") diff --git a/backend/src/collective/volto/formsupport/captcha/honeypot.py b/backend/src/collective/volto/formsupport/captcha/honeypot.py new file mode 100644 index 0000000..9546ff0 --- /dev/null +++ b/backend/src/collective/volto/formsupport/captcha/honeypot.py @@ -0,0 +1,43 @@ +from . import CaptchaSupport +from collective.honeypot.config import HONEYPOT_FIELD +from collective.honeypot.utils import found_honeypot +from collective.volto.formsupport import _ +from plone.restapi.deserializer import json_body +from zExceptions import BadRequest +from zope.i18n import translate + + +class HoneypotSupport(CaptchaSupport): + name = _("Honeypot Support") + + def isEnabled(self): + """ + Honeypot is enabled with env vars + """ + return True + + def serialize(self): + if not HONEYPOT_FIELD: + # no field is set, so we only want to log. + return {} + + return {"id": HONEYPOT_FIELD} + + def verify(self, data): + msg = translate( + _("honeypot_error", default="Error submitting form."), + context=self.request, + ) + # first check if volto-form-block send the compiled token + # (because by default it does not insert the honeypot field into the submitted form) + if not data: + # @submit-form has been called not from volto-form-block so do the standard validation. + form_data = json_body(self.request).get("data", []) + form = {x["label"]: x["value"] for x in form_data} + if found_honeypot(form, required=True): + raise BadRequest(msg) + return + if "value" not in data: + raise BadRequest(msg) + if data["value"] != "": + raise BadRequest(msg) diff --git a/backend/src/collective/volto/formsupport/captcha/norobots.py b/backend/src/collective/volto/formsupport/captcha/norobots.py new file mode 100644 index 0000000..0800f12 --- /dev/null +++ b/backend/src/collective/volto/formsupport/captcha/norobots.py @@ -0,0 +1,68 @@ +from . import CaptchaSupport +from collective.volto.formsupport import _ +from collective.z3cform.norobots.browser.interfaces import INorobotsWidgetSettings +from plone import api +from plone.registry.interfaces import IRegistry +from zExceptions import BadRequest +from zope.component import queryUtility +from zope.i18n import translate + +import json + + +class NoRobotsSupport(CaptchaSupport): + name = _("NoRobots ReCaptcha Support") + + def __init__(self, context, request): + super().__init__(context, request) + registry = queryUtility(IRegistry) + self.settings = registry.forInterface(INorobotsWidgetSettings, check=False) + + def isEnabled(self): + return self.settings and self.settings.questions + + def serialize(self): + if not self.settings.questions: + raise ValueError( + "No recaptcha public key configured. Go to " + "path/to/site/@@norobots-controlpanel to configure." + ) + + view = api.content.get_view( + context=self.context, request=self.request, name="norobots" + ) + + question = view.get_question() + question.update({"provider": "norobots-captcha"}) + return question + + def verify(self, data): + if not self.settings.questions: + raise ValueError( + "No question configured. Go to " + "path/to/site/@@norobots-controlpanel to configure." + ) + + if not data or not data.get("token"): + raise BadRequest( + translate( + _("No captcha token provided."), + context=self.request, + ) + ) + token = data["token"] + json_token = json.loads(token) + view = api.content.get_view( + context=self.context, request=self.request, name="norobots" + ) + value = json_token.get("value") + id = json_token.get("id") + id_check = json_token.get("id_check") + + if not view.verify(input=value, question_id=id, id_check=id_check): + raise BadRequest( + translate( + _("The code you entered was wrong, please enter the new one."), + context=self.request, + ) + ) diff --git a/backend/src/collective/volto/formsupport/captcha/recaptcha.py b/backend/src/collective/volto/formsupport/captcha/recaptcha.py new file mode 100644 index 0000000..c801ad9 --- /dev/null +++ b/backend/src/collective/volto/formsupport/captcha/recaptcha.py @@ -0,0 +1,57 @@ +from . import CaptchaSupport +from collective.volto.formsupport import _ +from plone.formwidget.recaptcha.interfaces import IReCaptchaSettings +from plone.formwidget.recaptcha.norecaptcha import submit +from plone.registry.interfaces import IRegistry +from zExceptions import BadRequest +from zope.component import queryUtility +from zope.i18n import translate + + +class RecaptchaSupport(CaptchaSupport): + name = _("Google ReCaptcha") + + def __init__(self, context, request): + super().__init__(context, request) + registry = queryUtility(IRegistry) + self.settings = registry.forInterface(IReCaptchaSettings, check=False) + + def isEnabled(self): + return self.settings and self.settings.public_key and self.settings.private_key + + def serialize(self): + if not self.settings.public_key: + raise ValueError( + "No recaptcha public key configured. Go to " + "path/to/site/@@recaptcha-settings to configure." + ) + return { + "provider": "recaptcha", + "public_key": self.settings.public_key, + } + + def verify(self, data): + if not self.settings.private_key: + raise ValueError( + "No recaptcha private key configured. Go to " + "path/to/site/@@recaptcha-settings to configure." + ) + if not data or not data.get("token"): + raise BadRequest( + translate( + _("No captcha token provided."), + context=self.request, + ) + ) + token = data["token"] + remote_addr = self.request.get("HTTP_X_FORWARDED_FOR", "").split(",")[0] + if not remote_addr: + remote_addr = self.request.get("REMOTE_ADDR") + res = submit(token, self.settings.private_key, remote_addr) + if not res.is_valid: + raise BadRequest( + translate( + _("The code you entered was wrong, please enter the new one."), + context=self.request, + ) + ) diff --git a/backend/src/collective/volto/formsupport/captcha/vocabularies.py b/backend/src/collective/volto/formsupport/captcha/vocabularies.py new file mode 100644 index 0000000..8e91e53 --- /dev/null +++ b/backend/src/collective/volto/formsupport/captcha/vocabularies.py @@ -0,0 +1,15 @@ +from ..interfaces import ICaptchaSupport +from zope.component import getAdapters +from zope.interface import provider +from zope.schema.interfaces import IVocabularyFactory +from zope.schema.vocabulary import SimpleTerm +from zope.schema.vocabulary import SimpleVocabulary + + +@provider(IVocabularyFactory) +def captcha_providers_vocabulary_factory(context): + terms = [] + for name, adapter in getAdapters((context, context.REQUEST), ICaptchaSupport): + if adapter.isEnabled(): + terms.append(SimpleTerm(value=name, token=name, title=adapter.name)) + return SimpleVocabulary(terms) diff --git a/backend/src/collective/volto/formsupport/configure.zcml b/backend/src/collective/volto/formsupport/configure.zcml new file mode 100644 index 0000000..0a8a7c6 --- /dev/null +++ b/backend/src/collective/volto/formsupport/configure.zcml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/datamanager/__init__.py b/backend/src/collective/volto/formsupport/datamanager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/datamanager/catalog.py b/backend/src/collective/volto/formsupport/datamanager/catalog.py new file mode 100644 index 0000000..f746119 --- /dev/null +++ b/backend/src/collective/volto/formsupport/datamanager/catalog.py @@ -0,0 +1,118 @@ +from collective.volto.formsupport import logger +from collective.volto.formsupport.interfaces import IFormDataStore +from collective.volto.formsupport.utils import get_blocks +from copy import deepcopy +from datetime import datetime +from plone.dexterity.interfaces import IDexterityContent +from plone.restapi.deserializer import json_body +from repoze.catalog.catalog import Catalog +from repoze.catalog.indexes.field import CatalogFieldIndex +from souper.interfaces import ICatalogFactory +from souper.soup import get_soup +from souper.soup import NodeAttributeIndexer +from souper.soup import Record +from zope.component import adapter +from zope.interface import implementer +from zope.interface import Interface + + +@implementer(ICatalogFactory) +class FormDataSoupCatalogFactory: + def __call__(self, context): + #  do not set any index here..maybe on each form + catalog = Catalog() + block_id_indexer = NodeAttributeIndexer("block_id") + catalog["block_id"] = CatalogFieldIndex(block_id_indexer) + return catalog + + +@implementer(IFormDataStore) +@adapter(IDexterityContent, Interface) +class FormDataStore: + def __init__(self, context, request): + self.context = context + self.request = request + + @property + def soup(self): + return get_soup("form_data", self.context) + + @property + def block_id(self): + data = json_body(self.request) + if not data: + data = self.request.form + return data.get("block_id", "") + + def get_form_fields(self): + blocks = get_blocks(self.context) + + if not blocks: + return {} + form_block = {} + for id, block in blocks.items(): + if id != self.block_id: + continue + block_type = block.get("@type", "") + if block_type == "form": + form_block = deepcopy(block) + if not form_block: + return {} + + subblocks = form_block.get("subblocks", []) + + # Add the 'custom_field_id' field back in as this isn't stored with each subblock + for index, field in enumerate(subblocks): + if form_block.get(field["field_id"]): + subblocks[index]["custom_field_id"] = form_block.get(field["field_id"]) + + return subblocks + + def add(self, data): + form_fields = self.get_form_fields() + if not form_fields: + logger.error( + 'Block with id {} and type "form" not found in context: {}.'.format( + self.block_id, self.context.absolute_url() + ) + ) + return None + + fields = { + x["field_id"]: x.get("custom_field_id", x.get("label", x["field_id"])) + for x in form_fields + } + record = Record() + fields_labels = {} + fields_order = [] + for field_data in data: + field_id = field_data.get("field_id", "") + value = field_data.get("value", "") + if field_id in fields: + record.attrs[field_id] = value + fields_labels[field_id] = fields[field_id] + fields_order.append(field_id) + record.attrs["fields_labels"] = fields_labels + record.attrs["fields_order"] = fields_order + record.attrs["date"] = datetime.now() + record.attrs["block_id"] = self.block_id + return self.soup.add(record) + + def length(self): + return len([x for x in self.soup.data.values()]) + + def search(self, query=None): + if not query: + records = sorted( + self.soup.data.values(), + key=lambda k: k.attrs.get("date", ""), + reverse=True, + ) + return records + + def delete(self, id): + record = self.soup.get(id) + del self.soup[record] + + def clear(self): + self.soup.clear() diff --git a/backend/src/collective/volto/formsupport/datamanager/configure.zcml b/backend/src/collective/volto/formsupport/datamanager/configure.zcml new file mode 100644 index 0000000..e71ad0c --- /dev/null +++ b/backend/src/collective/volto/formsupport/datamanager/configure.zcml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/interfaces.py b/backend/src/collective/volto/formsupport/interfaces.py new file mode 100644 index 0000000..d2218a4 --- /dev/null +++ b/backend/src/collective/volto/formsupport/interfaces.py @@ -0,0 +1,46 @@ +from zope.interface import Interface +from zope.publisher.interfaces.browser import IDefaultBrowserLayer + + +class ICollectiveVoltoFormsupportLayer(IDefaultBrowserLayer): + """Marker interface that defines a browser layer.""" + + +class IFormDataStore(Interface): + def add(data): + """ + Add data to the store + + @return: record id + """ + + def length(): + """ + @return: number of items stored into store + """ + + def search(query): + """ + @return: items that match query + """ + + +class IPostEvent(Interface): + """ + Event fired when a form is submitted (before actions) + """ + + +class ICaptchaSupport(Interface): + def __init__(context, request): + """Initialize adapter""" + + def is_enabled(): + """Captcha method enabled + @return: True if the method is enabled/configured + """ + + def verify(data): + """Verify the captcha + @return: True if verified, Raise exception otherwise + """ diff --git a/backend/src/collective/volto/formsupport/locales/README.rst b/backend/src/collective/volto/formsupport/locales/README.rst new file mode 100644 index 0000000..c0b3cdf --- /dev/null +++ b/backend/src/collective/volto/formsupport/locales/README.rst @@ -0,0 +1,37 @@ +Adding and updating locales +--------------------------- + +For every language you want to translate into you need a +locales/[language]/LC_MESSAGES/collective.task.po +(e.g. locales/de/LC_MESSAGES/collective.task.po) + +For German + +.. code-block:: console + + $ mkdir de + +For updating locales + +.. code-block:: console + + $ ./bin/update_locale + +Note +---- + +The script uses gettext package for internationalization. + +Install it before running the script. + +On macOS +-------- + +.. code-block:: console + + $ brew install gettext + +On Windows +---------- + +see https://mlocati.github.io/articles/gettext-iconv-windows.html diff --git a/backend/src/collective/volto/formsupport/locales/__init__.py b/backend/src/collective/volto/formsupport/locales/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot b/backend/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot new file mode 100644 index 0000000..e64e4fc --- /dev/null +++ b/backend/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot @@ -0,0 +1,157 @@ +#--- PLEASE EDIT THE LINES BELOW CORRECTLY --- +#SOME DESCRIPTIVE TITLE. +#FIRST AUTHOR , YEAR. +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-06-07 00:42+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0\n" +"Language-Code: en\n" +"Language-Name: English\n" +"Preferred-Encodings: utf-8 latin1\n" +"Domain: collective.volto.formsupport\n" + +#: ../restapi/services/validation/email.py:51 +msgid "Email confirmation code" +msgstr "" + +#: ../captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "" + +#: ../captcha/hcaptcha.py:14 +msgid "HCaptcha" +msgstr "" + +#: ../captcha/hcaptcha.py:63 +msgid "HCaptcha Invisible" +msgstr "" + +#: ../captcha/honeypot.py:11 +msgid "Honeypot Support" +msgstr "" + +#: ../configure.zcml:34 +msgid "Installs the collective.volto.formsupport add-on." +msgstr "" + +#: ../captcha/hcaptcha.py:44 +#: ../captcha/norobots.py:49 +#: ../captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "" + +#: ../captcha/norobots.py:14 +msgid "NoRobots ReCaptcha Support" +msgstr "" + +#: ../restapi/services/validation/email.py:94 +msgid "OTP is wrong" +msgstr "" + +#: ../captcha/hcaptcha.py:56 +#: ../captcha/norobots.py:65 +#: ../captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "" + +#: ../restapi/services/validation/email.py:64 +msgid "The email field is missing" +msgstr "" + +#: ../browser/templates/email_confirm_view.pt:386 +msgid "The form on ${page} was compiled with this email address, if it was not you, ignore the message." +msgstr "" + +#: ../restapi/services/validation/email.py:88 +msgid "The otp field is missing" +msgstr "" + +#: ../restapi/services/validation/email.py:67 +msgid "The provided email address is not valid" +msgstr "" + +#: ../restapi/services/validation/email.py:70 +msgid "The uid field is missing" +msgstr "" + +#: ../configure.zcml:43 +msgid "Uninstalls the collective.volto.formsupport add-on." +msgstr "" + +#: ../configure.zcml:34 +msgid "Volto: Form support" +msgstr "" + +#: ../configure.zcml:43 +msgid "Volto: Form support (uninstall)" +msgstr "" + +#: ../browser/templates/email_confirm_view.pt:371 +msgid "Your code to validate the email address:" +msgstr "" + +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: ../restapi/services/submit_form/post.py:215 +msgid "attachments_too_big" +msgstr "" + +#. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" +#: ../restapi/services/submit_form/post.py:130 +msgid "block_form_not_found_label" +msgstr "" + +#. Default: "Empty form data." +#: ../restapi/services/submit_form/post.py:156 +msgid "empty_form_data" +msgstr "" + +#. Default: "Error submitting form." +#: ../captcha/honeypot.py:28 +msgid "honeypot_error" +msgstr "" + +#. Default: "Unable to send confirm email. Please retry later or contact site administrator." +#: ../restapi/services/submit_form/post.py:76 +msgid "mail_send_exception" +msgstr "" + +#. Default: "You need to set at least one form action between \"send\" and \"store\"." +#: ../restapi/services/submit_form/post.py:145 +msgid "missing_action" +msgstr "" + +#. Default: "Missing block_id" +#: ../restapi/services/submit_form/post.py:123 +msgid "missing_blockid_label" +msgstr "" + +#. Default: "A new form has been submitted from ${url}" +#: ../browser/send_mail_template.pt:22 +msgid "send_mail_text" +msgstr "" + +#. Default: "Form submission data for ${title}" +#: ../browser/send_mail_template_table.pt:15 +msgid "send_mail_text_table" +msgstr "" + +#. Default: "Missing required field: subject or from." +#: ../restapi/services/submit_form/post.py:322 +msgid "send_required_field_missing" +msgstr "" + +#. Default: "Email not valid in \"${field}\" field." +#: ../restapi/services/submit_form/post.py:187 +msgid "wrong_email" +msgstr "" + +#: ../restapi/services/submit_form/post.py:246 +msgid "{email}'s OTP is wrong" +msgstr "" diff --git a/backend/src/collective/volto/formsupport/locales/de/LC_MESSAGES/collective.volto.formsupport.po b/backend/src/collective/volto/formsupport/locales/de/LC_MESSAGES/collective.volto.formsupport.po new file mode 100644 index 0000000..c1d4c71 --- /dev/null +++ b/backend/src/collective/volto/formsupport/locales/de/LC_MESSAGES/collective.volto.formsupport.po @@ -0,0 +1,154 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-06-07 00:42+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0\n" +"Language-Code: en\n" +"Language-Name: English\n" +"Preferred-Encodings: utf-8 latin1\n" +"Domain: DOMAIN\n" + +#: ../restapi/services/validation/email.py:51 +msgid "Email confirmation code" +msgstr "" + +#: ../captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "" + +#: ../captcha/hcaptcha.py:14 +msgid "HCaptcha" +msgstr "" + +#: ../captcha/hcaptcha.py:63 +msgid "HCaptcha Invisible" +msgstr "" + +#: ../captcha/honeypot.py:11 +msgid "Honeypot Support" +msgstr "" + +#: ../configure.zcml:34 +msgid "Installs the collective.volto.formsupport add-on." +msgstr "" + +#: ../captcha/hcaptcha.py:44 +#: ../captcha/norobots.py:49 +#: ../captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "Kein Captcha-Token angegeben." + +#: ../captcha/norobots.py:14 +msgid "NoRobots ReCaptcha Support" +msgstr "" + +#: ../restapi/services/validation/email.py:94 +msgid "OTP is wrong" +msgstr "" + +#: ../captcha/hcaptcha.py:56 +#: ../captcha/norobots.py:65 +#: ../captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "Der eingegebene Code war falsch. Bitte geben Sie den neuen Code ein." + +#: ../restapi/services/validation/email.py:64 +msgid "The email field is missing" +msgstr "" + +#: ../browser/templates/email_confirm_view.pt:386 +msgid "The form on ${page} was compiled with this email address, if it was not you, ignore the message." +msgstr "" + +#: ../restapi/services/validation/email.py:88 +msgid "The otp field is missing" +msgstr "" + +#: ../restapi/services/validation/email.py:67 +msgid "The provided email address is not valid" +msgstr "" + +#: ../restapi/services/validation/email.py:70 +msgid "The uid field is missing" +msgstr "" + +#: ../configure.zcml:43 +msgid "Uninstalls the collective.volto.formsupport add-on." +msgstr "" + +#: ../configure.zcml:34 +msgid "Volto: Form support" +msgstr "" + +#: ../configure.zcml:43 +msgid "Volto: Form support (uninstall)" +msgstr "" + +#: ../browser/templates/email_confirm_view.pt:371 +msgid "Your code to validate the email address:" +msgstr "" + +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: ../restapi/services/submit_form/post.py:215 +msgid "attachments_too_big" +msgstr "Anhang ist zu groß (${uploaded_str}). Die Größe darf ${max} MB nicht überschreiten. Versuchen Sie Dateien zu komprimieren." + +#. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" +#: ../restapi/services/submit_form/post.py:130 +msgid "block_form_not_found_label" +msgstr "Kein Formular-Block mit der ID \"$block\" in diesem Kontext gefunden: $context" + +#. Default: "Empty form data." +#: ../restapi/services/submit_form/post.py:156 +msgid "empty_form_data" +msgstr "Leere Formulardaten." + +#. Default: "Error submitting form." +#: ../captcha/honeypot.py:28 +msgid "honeypot_error" +msgstr "Formular konnte nicht abgeschickt werden." + +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: ../restapi/services/submit_form/post.py:76 +msgid "mail_send_exception" +msgstr "Die Bestätigungsmail konnte nicht versandt werden. Bitte versuchen Sie es später erneut oder kontaktieren Sie die Seitenadministration." + +#. Default: "You need to set at least one form action between \"send\" and \"store\"." +#: ../restapi/services/submit_form/post.py:145 +msgid "missing_action" +msgstr "Es muss mindestens eine Formular-Aktion gesetzt werden (\"E-Mail an Empfänger senden\" und/oder \"Kompilierte Daten speichern\")." + +#. Default: "Missing block_id" +#: ../restapi/services/submit_form/post.py:123 +msgid "missing_blockid_label" +msgstr "Fehlende block_id" + +#. Default: "A new form has been submitted from ${url}" +#: ../browser/send_mail_template.pt:22 +msgid "send_mail_text" +msgstr "Auf der Seite ${url} wurde ein neues Formular eingereicht:" + +#. Default: "Form submission data for ${title}" +#: ../browser/send_mail_template_table.pt:15 +msgid "send_mail_text_table" +msgstr "" + +#. Default: "Missing required field: subject or from." +#: ../restapi/services/submit_form/post.py:322 +msgid "send_required_field_missing" +msgstr "Erforderliches Feld fehlt: Betreff oder Absender" + +#. Default: "Email not valid in \"${field}\" field." +#: ../restapi/services/submit_form/post.py:187 +msgid "wrong_email" +msgstr "" + +#: ../restapi/services/submit_form/post.py:246 +msgid "{email}'s OTP is wrong" +msgstr "" diff --git a/backend/src/collective/volto/formsupport/locales/es/LC_MESSAGES/collective.volto.formsupport.po b/backend/src/collective/volto/formsupport/locales/es/LC_MESSAGES/collective.volto.formsupport.po new file mode 100644 index 0000000..6af8865 --- /dev/null +++ b/backend/src/collective/volto/formsupport/locales/es/LC_MESSAGES/collective.volto.formsupport.po @@ -0,0 +1,159 @@ +# Translation of collective.volto.formsupport.pot to Spanish +# Leonardo J. Caballero G. , 2023. +msgid "" +msgstr "" +"Project-Id-Version: collective.volto.formsupport\n" +"POT-Creation-Date: 2024-06-07 00:42+0000\n" +"PO-Revision-Date: 2023-05-11 10:11-0400\n" +"Last-Translator: Leonardo J. Caballero G. \n" +"Language-Team: ES \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language-Code: es\n" +"Language-Name: Español\n" +"Preferred-Encodings: utf-8\n" +"Domain: collective.volto.formsupport\n" +"Language: es\n" +"X-Is-Fallback-For: es-ar es-bo es-cl es-co es-cr es-do es-ec es-es es-sv es-gt es-hn es-mx es-ni es-pa es-py es-pe es-pr es-us es-uy es-ve\n" +"X-Generator: Poedit 2.2.1\n" + +#: ../restapi/services/validation/email.py:51 +msgid "Email confirmation code" +msgstr "" + +#: ../captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "Google ReCaptcha" + +#: ../captcha/hcaptcha.py:14 +msgid "HCaptcha" +msgstr "HCaptcha" + +#: ../captcha/hcaptcha.py:63 +msgid "HCaptcha Invisible" +msgstr "HCaptcha invisible" + +#: ../captcha/honeypot.py:11 +msgid "Honeypot Support" +msgstr "" + +#: ../configure.zcml:34 +msgid "Installs the collective.volto.formsupport add-on." +msgstr "Instala o complemento collective.volto.formsupport." + +#: ../captcha/hcaptcha.py:44 +#: ../captcha/norobots.py:49 +#: ../captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "No se proporcionó ningún token captcha." + +#: ../captcha/norobots.py:14 +msgid "NoRobots ReCaptcha Support" +msgstr "Soporte de NoRobots ReCaptcha" + +#: ../restapi/services/validation/email.py:94 +msgid "OTP is wrong" +msgstr "" + +#: ../captcha/hcaptcha.py:56 +#: ../captcha/norobots.py:65 +#: ../captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "El código que ingresaste es incorrecto, ingresa el nuevo." + +#: ../restapi/services/validation/email.py:64 +msgid "The email field is missing" +msgstr "" + +#: ../browser/templates/email_confirm_view.pt:386 +msgid "The form on ${page} was compiled with this email address, if it was not you, ignore the message." +msgstr "" + +#: ../restapi/services/validation/email.py:88 +msgid "The otp field is missing" +msgstr "" + +#: ../restapi/services/validation/email.py:67 +msgid "The provided email address is not valid" +msgstr "" + +#: ../restapi/services/validation/email.py:70 +msgid "The uid field is missing" +msgstr "" + +#: ../configure.zcml:43 +msgid "Uninstalls the collective.volto.formsupport add-on." +msgstr "Desinstala o complemento collective.volto.formsupport." + +#: ../configure.zcml:34 +msgid "Volto: Form support" +msgstr "Volto: Soporte a formularios" + +#: ../configure.zcml:43 +msgid "Volto: Form support (uninstall)" +msgstr "Volto: Soporte a formularios (Desinstalar)" + +#: ../browser/templates/email_confirm_view.pt:371 +msgid "Your code to validate the email address:" +msgstr "" + +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: ../restapi/services/submit_form/post.py:215 +msgid "attachments_too_big" +msgstr "Adjuntos demasiado grandes. Subiste el archivo ${uploaded_str}, pero el límite es de ${max} MB. Intenta comprimir archivos." + +#. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" +#: ../restapi/services/submit_form/post.py:130 +msgid "block_form_not_found_label" +msgstr "Bloque con @type \"form\" y id \"${block}\" no encontrado en contexto: $context." + +#. Default: "Empty form data." +#: ../restapi/services/submit_form/post.py:156 +msgid "empty_form_data" +msgstr "Formulario sem dados." + +#. Default: "Error submitting form." +#: ../captcha/honeypot.py:28 +msgid "honeypot_error" +msgstr "" + +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: ../restapi/services/submit_form/post.py:76 +msgid "mail_send_exception" +msgstr "No se puede enviar el correo electrónico de confirmación. Vuelva a intentarlo más tarde o póngase en contacto con el administrador del sitio." + +#. Default: "You need to set at least one form action between \"send\" and \"store\"." +#: ../restapi/services/submit_form/post.py:145 +msgid "missing_action" +msgstr "Debe seleccionar al menos una acción entre \"guardar\" y \"enviar\"." + +#. Default: "Missing block_id" +#: ../restapi/services/submit_form/post.py:123 +msgid "missing_blockid_label" +msgstr "Campo block_id no informado" + +#. Default: "A new form has been submitted from ${url}" +#: ../browser/send_mail_template.pt:22 +msgid "send_mail_text" +msgstr "Se ha enviado un nuevo formulario desde ${url}:" + +#. Default: "Form submission data for ${title}" +#: ../browser/send_mail_template_table.pt:15 +msgid "send_mail_text_table" +msgstr "" + +#. Default: "Missing required field: subject or from." +#: ../restapi/services/submit_form/post.py:322 +msgid "send_required_field_missing" +msgstr "Campo obligatorio no presente: Asunto o remitente." + +#. Default: "Email not valid in \"${field}\" field." +#: ../restapi/services/submit_form/post.py:187 +msgid "wrong_email" +msgstr "" + +#: ../restapi/services/submit_form/post.py:246 +msgid "{email}'s OTP is wrong" +msgstr "" diff --git a/backend/src/collective/volto/formsupport/locales/it/LC_MESSAGES/collective.volto.formsupport.po b/backend/src/collective/volto/formsupport/locales/it/LC_MESSAGES/collective.volto.formsupport.po new file mode 100644 index 0000000..ea816f3 --- /dev/null +++ b/backend/src/collective/volto/formsupport/locales/it/LC_MESSAGES/collective.volto.formsupport.po @@ -0,0 +1,154 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-06-07 00:42+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0\n" +"Language-Code: en\n" +"Language-Name: English\n" +"Preferred-Encodings: utf-8 latin1\n" +"Domain: DOMAIN\n" + +#: ../restapi/services/validation/email.py:51 +msgid "Email confirmation code" +msgstr "Codice di conferma dell'email" + +#: ../captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "Google ReCaptcha" + +#: ../captcha/hcaptcha.py:14 +msgid "HCaptcha" +msgstr "HCaptcha" + +#: ../captcha/hcaptcha.py:63 +msgid "HCaptcha Invisible" +msgstr "HCaptcha Invisible" + +#: ../captcha/honeypot.py:11 +msgid "Honeypot Support" +msgstr "Honeypot" + +#: ../configure.zcml:34 +msgid "Installs the collective.volto.formsupport add-on." +msgstr "Install collective.volto.formsupport" + +#: ../captcha/hcaptcha.py:44 +#: ../captcha/norobots.py:49 +#: ../captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "Nessun token captcha fornito." + +#: ../captcha/norobots.py:14 +msgid "NoRobots ReCaptcha Support" +msgstr "NoRobots" + +#: ../restapi/services/validation/email.py:94 +msgid "OTP is wrong" +msgstr "Il codice OTP è errato." + +#: ../captcha/hcaptcha.py:56 +#: ../captcha/norobots.py:65 +#: ../captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "Il codice che hai inserito è sbagliato, per favore prova con un altro." + +#: ../restapi/services/validation/email.py:64 +msgid "The email field is missing" +msgstr "Campo email è mancante" + +#: ../browser/templates/email_confirm_view.pt:386 +msgid "The form on ${page} was compiled with this email address, if it was not you, ignore the message." +msgstr "E' stato compilato un form nella pagina ${page}. Per favore inserisci questo codice di conferma per verificare che sei stato tu. Se non sei stato tu, ignora questa mail." + +#: ../restapi/services/validation/email.py:88 +msgid "The otp field is missing" +msgstr "Manca il campo OTP." + +#: ../restapi/services/validation/email.py:67 +msgid "The provided email address is not valid" +msgstr "L'email usato non è valido" + +#: ../restapi/services/validation/email.py:70 +msgid "The uid field is missing" +msgstr "Campo uid è mancante" + +#: ../configure.zcml:43 +msgid "Uninstalls the collective.volto.formsupport add-on." +msgstr "Disinstalla collective.volto.formsupport." + +#: ../configure.zcml:34 +msgid "Volto: Form support" +msgstr "Volto: Form support" + +#: ../configure.zcml:43 +msgid "Volto: Form support (uninstall)" +msgstr "Volto: Form support (uninstall)" + +#: ../browser/templates/email_confirm_view.pt:371 +msgid "Your code to validate the email address:" +msgstr "Il tuo codice per la conferma dell'indirizzo email:" + +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: ../restapi/services/submit_form/post.py:215 +msgid "attachments_too_big" +msgstr "Allegati troppo grandi. Hai caricato ${uploaded_str}, ma il limite è di ${max} MB. Prova a comprimerli." + +#. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" +#: ../restapi/services/submit_form/post.py:130 +msgid "block_form_not_found_label" +msgstr "Blocco con @type \"form\" e id \"$block\" non trovato nel contesto: $context" + +#. Default: "Empty form data." +#: ../restapi/services/submit_form/post.py:156 +msgid "empty_form_data" +msgstr "Form senza dati." + +#. Default: "Error submitting form." +#: ../captcha/honeypot.py:28 +msgid "honeypot_error" +msgstr "Errore nella sottomissione del form." + +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: ../restapi/services/submit_form/post.py:76 +msgid "mail_send_exception" +msgstr "Impossibile inviare la mail di conferma. Per favore riprova più tardi o contatta l'amministratore del sito." + +#. Default: "You need to set at least one form action between \"send\" and \"store\"." +#: ../restapi/services/submit_form/post.py:145 +msgid "missing_action" +msgstr "Devi selezionare almeno un'azione tra \"salva\" e \"invia\"." + +#. Default: "Missing block_id" +#: ../restapi/services/submit_form/post.py:123 +msgid "missing_blockid_label" +msgstr "Campo block_id mancante." + +#. Default: "A new form has been submitted from ${url}" +#: ../browser/send_mail_template.pt:22 +msgid "send_mail_text" +msgstr "Modulo compilato su ${url}" + +#. Default: "Form submission data for ${title}" +#: ../browser/send_mail_template_table.pt:15 +msgid "send_mail_text_table" +msgstr "Dati inviati per ${title}" + +#. Default: "Missing required field: subject or from." +#: ../restapi/services/submit_form/post.py:322 +msgid "send_required_field_missing" +msgstr "Campo obbligatorio mancante: subject o from." + +#. Default: "Email not valid in \"${field}\" field." +#: ../restapi/services/submit_form/post.py:187 +msgid "wrong_email" +msgstr "Email inserita non valida nel campo \"${field}\"." + +#: ../restapi/services/submit_form/post.py:246 +msgid "{email}'s OTP is wrong" +msgstr "Il codice OTP per {email} è errato." diff --git a/backend/src/collective/volto/formsupport/locales/pt_BR/LC_MESSAGES/collective.volto.formsupport.po b/backend/src/collective/volto/formsupport/locales/pt_BR/LC_MESSAGES/collective.volto.formsupport.po new file mode 100644 index 0000000..d6d345a --- /dev/null +++ b/backend/src/collective/volto/formsupport/locales/pt_BR/LC_MESSAGES/collective.volto.formsupport.po @@ -0,0 +1,156 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2024-06-07 00:42+0000\n" +"PO-Revision-Date: 2021-05-11 18:49-0300\n" +"Last-Translator: Érico Andrei , 2021\n" +"Language-Team: Portuguese (https://www.transifex.com/plone/teams/14552/pt/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Language-Code: pt-br\n" +"Language-Name: Português do Brasil\n" +"Preferred-Encodings: utf-8 latin1\n" +"Domain: collective.volto.formsupport\n" +"Language: pt_BR\n" +"X-Generator: Poedit 2.4.3\n" + +#: ../restapi/services/validation/email.py:51 +msgid "Email confirmation code" +msgstr "" + +#: ../captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "" + +#: ../captcha/hcaptcha.py:14 +msgid "HCaptcha" +msgstr "" + +#: ../captcha/hcaptcha.py:63 +msgid "HCaptcha Invisible" +msgstr "" + +#: ../captcha/honeypot.py:11 +msgid "Honeypot Support" +msgstr "" + +#: ../configure.zcml:34 +msgid "Installs the collective.volto.formsupport add-on." +msgstr "Instala o complemento collective.volto.formsupport." + +#: ../captcha/hcaptcha.py:44 +#: ../captcha/norobots.py:49 +#: ../captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "" + +#: ../captcha/norobots.py:14 +msgid "NoRobots ReCaptcha Support" +msgstr "" + +#: ../restapi/services/validation/email.py:94 +msgid "OTP is wrong" +msgstr "" + +#: ../captcha/hcaptcha.py:56 +#: ../captcha/norobots.py:65 +#: ../captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "" + +#: ../restapi/services/validation/email.py:64 +msgid "The email field is missing" +msgstr "" + +#: ../browser/templates/email_confirm_view.pt:386 +msgid "The form on ${page} was compiled with this email address, if it was not you, ignore the message." +msgstr "" + +#: ../restapi/services/validation/email.py:88 +msgid "The otp field is missing" +msgstr "" + +#: ../restapi/services/validation/email.py:67 +msgid "The provided email address is not valid" +msgstr "" + +#: ../restapi/services/validation/email.py:70 +msgid "The uid field is missing" +msgstr "" + +#: ../configure.zcml:43 +msgid "Uninstalls the collective.volto.formsupport add-on." +msgstr "Desinstala o complemento collective.volto.formsupport." + +#: ../configure.zcml:34 +msgid "Volto: Form support" +msgstr "Volto: Suporte a formulários" + +#: ../configure.zcml:43 +msgid "Volto: Form support (uninstall)" +msgstr "Volto: Suporte a formulários (Desinstalar)" + +#: ../browser/templates/email_confirm_view.pt:371 +msgid "Your code to validate the email address:" +msgstr "" + +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: ../restapi/services/submit_form/post.py:215 +msgid "attachments_too_big" +msgstr "" + +#. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" +#: ../restapi/services/submit_form/post.py:130 +msgid "block_form_not_found_label" +msgstr "Bloco com @type \"form\" e id \"${block}\" não encontrado no contexto: $context." + +#. Default: "Empty form data." +#: ../restapi/services/submit_form/post.py:156 +msgid "empty_form_data" +msgstr "Formulário sem dados." + +#. Default: "Error submitting form." +#: ../captcha/honeypot.py:28 +msgid "honeypot_error" +msgstr "" + +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: ../restapi/services/submit_form/post.py:76 +msgid "mail_send_exception" +msgstr "" + +#. Default: "You need to set at least one form action between \"send\" and \"store\"." +#: ../restapi/services/submit_form/post.py:145 +msgid "missing_action" +msgstr "Você deve selecionar pelo menos uma ação entre \"salvar\" e \"enviar\"." + +#. Default: "Missing block_id" +#: ../restapi/services/submit_form/post.py:123 +msgid "missing_blockid_label" +msgstr "Campo block_id não informado" + +#. Default: "A new form has been submitted from ${url}" +#: ../browser/send_mail_template.pt:22 +msgid "send_mail_text" +msgstr "Um novo formulário foi preenchido em ${url}:" + +#. Default: "Form submission data for ${title}" +#: ../browser/send_mail_template_table.pt:15 +msgid "send_mail_text_table" +msgstr "" + +#. Default: "Missing required field: subject or from." +#: ../restapi/services/submit_form/post.py:322 +msgid "send_required_field_missing" +msgstr "Campo obrigatório não presente: Assunto ou remetente." + +#. Default: "Email not valid in \"${field}\" field." +#: ../restapi/services/submit_form/post.py:187 +msgid "wrong_email" +msgstr "" + +#: ../restapi/services/submit_form/post.py:246 +msgid "{email}'s OTP is wrong" +msgstr "" diff --git a/backend/src/collective/volto/formsupport/locales/update.py b/backend/src/collective/volto/formsupport/locales/update.py new file mode 100644 index 0000000..a548b8a --- /dev/null +++ b/backend/src/collective/volto/formsupport/locales/update.py @@ -0,0 +1,72 @@ +import os +import pkg_resources +import subprocess + + +domain = "collective.volto.formsupport" +os.chdir(pkg_resources.resource_filename(domain, "")) +os.chdir("../../../../") +target_path = "src/collective/volto/formsupport/" +locale_path = target_path + "locales/" +i18ndude = "./bin/i18ndude" + +# ignore node_modules files resulting in errors +excludes = '"*.html *json-schema*.xml"' + + +def locale_folder_setup(): + os.chdir(locale_path) + languages = [d for d in os.listdir(".") if os.path.isdir(d)] + for lang in languages: + folder = os.listdir(lang) + if "LC_MESSAGES" in folder: + continue + else: + lc_messages_path = lang + "/LC_MESSAGES/" + os.mkdir(lc_messages_path) + cmd = "msginit --locale={} --input={}.pot --output={}/LC_MESSAGES/{}.po".format( # NOQA: E501 + lang, + domain, + lang, + domain, + ) + subprocess.call( + cmd, + shell=True, + ) + + os.chdir("../../../../../") + + +def _rebuild(): + cmd = "{i18ndude} rebuild-pot --pot {locale_path}/{domain}.pot --exclude {excludes} --create {domain} {target_path}".format( # NOQA: E501 + i18ndude=i18ndude, + locale_path=locale_path, + domain=domain, + target_path=target_path, + excludes=excludes, + ) + subprocess.call( + cmd, + shell=True, + ) + + +def _sync(): + cmd = "{} sync --pot {}/{}.pot {}*/LC_MESSAGES/{}.po".format( + i18ndude, + locale_path, + domain, + locale_path, + domain, + ) + subprocess.call( + cmd, + shell=True, + ) + + +def update_locale(): + locale_folder_setup() + _sync() + _rebuild() diff --git a/backend/src/collective/volto/formsupport/locales/update.sh b/backend/src/collective/volto/formsupport/locales/update.sh new file mode 100755 index 0000000..b864bf1 --- /dev/null +++ b/backend/src/collective/volto/formsupport/locales/update.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# i18ndude should be available in current $PATH (eg by running +# ``export PATH=$PATH:$BUILDOUT_DIR/bin`` when i18ndude is located in your buildout's bin directory) +# +# For every language you want to translate into you need a +# locales/[language]/LC_MESSAGES/collective.volto.formsupport.po +# (e.g. locales/de/LC_MESSAGES/collective.volto.formsupport.po) + +domain=collective.volto.formsupport + +pipx run i18ndude rebuild-pot --pot $domain.pot --create $domain ../ +pipx run i18ndude sync --pot $domain.pot */LC_MESSAGES/$domain.po diff --git a/backend/src/collective/volto/formsupport/permissions.zcml b/backend/src/collective/volto/formsupport/permissions.zcml new file mode 100644 index 0000000..74de0f4 --- /dev/null +++ b/backend/src/collective/volto/formsupport/permissions.zcml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/profiles/default/browserlayer.xml b/backend/src/collective/volto/formsupport/profiles/default/browserlayer.xml new file mode 100644 index 0000000..2eed35f --- /dev/null +++ b/backend/src/collective/volto/formsupport/profiles/default/browserlayer.xml @@ -0,0 +1,6 @@ + + + + diff --git a/backend/src/collective/volto/formsupport/profiles/default/catalog.xml b/backend/src/collective/volto/formsupport/profiles/default/catalog.xml new file mode 100644 index 0000000..de5f459 --- /dev/null +++ b/backend/src/collective/volto/formsupport/profiles/default/catalog.xml @@ -0,0 +1,4 @@ + + + + diff --git a/backend/src/collective/volto/formsupport/profiles/default/metadata.xml b/backend/src/collective/volto/formsupport/profiles/default/metadata.xml new file mode 100644 index 0000000..fe3f329 --- /dev/null +++ b/backend/src/collective/volto/formsupport/profiles/default/metadata.xml @@ -0,0 +1,7 @@ + + + 1300 + + + + diff --git a/backend/src/collective/volto/formsupport/profiles/default/registry/main.xml b/backend/src/collective/volto/formsupport/profiles/default/registry/main.xml new file mode 100644 index 0000000..b6314fd --- /dev/null +++ b/backend/src/collective/volto/formsupport/profiles/default/registry/main.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/backend/src/collective/volto/formsupport/profiles/default/rolemap.xml b/backend/src/collective/volto/formsupport/profiles/default/rolemap.xml new file mode 100644 index 0000000..ac7676e --- /dev/null +++ b/backend/src/collective/volto/formsupport/profiles/default/rolemap.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/backend/src/collective/volto/formsupport/profiles/uninstall/browserlayer.xml b/backend/src/collective/volto/formsupport/profiles/uninstall/browserlayer.xml new file mode 100644 index 0000000..e4a8a98 --- /dev/null +++ b/backend/src/collective/volto/formsupport/profiles/uninstall/browserlayer.xml @@ -0,0 +1,6 @@ + + + + diff --git a/backend/src/collective/volto/formsupport/restapi/__init__.py b/backend/src/collective/volto/formsupport/restapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/restapi/configure.zcml b/backend/src/collective/volto/formsupport/restapi/configure.zcml new file mode 100644 index 0000000..0ed6875 --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/configure.zcml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/restapi/deserializer/__init__.py b/backend/src/collective/volto/formsupport/restapi/deserializer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/restapi/deserializer/blocks.py b/backend/src/collective/volto/formsupport/restapi/deserializer/blocks.py new file mode 100644 index 0000000..943ec94 --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/deserializer/blocks.py @@ -0,0 +1,40 @@ +from plone.dexterity.interfaces import IDexterityContent +from plone.restapi.bbb import IPloneSiteRoot +from plone.restapi.interfaces import IBlockFieldDeserializationTransformer +from Products.PortalTransforms.transforms.safe_html import SafeHTML +from zope.component import adapter +from zope.interface import implementer +from zope.publisher.interfaces.browser import IBrowserRequest + + +class FormBlockDeserializerBase: + """FormBlockDeserializerBase.""" + + order = 100 + + block_type = "form" + + def __init__(self, context, request): + self.context = context + self.request = request + + def __call__(self, value): + """ + Do not store html but only plaintext + """ + if value.get("send_message", ""): + transform = SafeHTML() + value["send_message"] = transform.scrub_html(value["send_message"]) + return value + + +@adapter(IDexterityContent, IBrowserRequest) +@implementer(IBlockFieldDeserializationTransformer) +class FormBlockDeserializer(FormBlockDeserializerBase): + """Deserializer for content-types that implements IBlocks behavior""" + + +@adapter(IPloneSiteRoot, IBrowserRequest) +@implementer(IBlockFieldDeserializationTransformer) +class FormBlockDeserializerRoot(FormBlockDeserializerBase): + """Deserializer for site root""" diff --git a/backend/src/collective/volto/formsupport/restapi/deserializer/configure.zcml b/backend/src/collective/volto/formsupport/restapi/deserializer/configure.zcml new file mode 100644 index 0000000..f31953a --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/deserializer/configure.zcml @@ -0,0 +1,14 @@ + + + + + diff --git a/backend/src/collective/volto/formsupport/restapi/serializer/__init__.py b/backend/src/collective/volto/formsupport/restapi/serializer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/restapi/serializer/blocks.py b/backend/src/collective/volto/formsupport/restapi/serializer/blocks.py new file mode 100644 index 0000000..9b70dd6 --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/serializer/blocks.py @@ -0,0 +1,59 @@ +from collective.volto.formsupport.interfaces import ICaptchaSupport +from collective.volto.formsupport.interfaces import ICollectiveVoltoFormsupportLayer +from plone import api + + +try: + from plone.base.interfaces import IPloneSiteRoot +except ImportError: + from Products.CMFPlone.interfaces import IPloneSiteRoot + +from plone.restapi.behaviors import IBlocks +from plone.restapi.interfaces import IBlockFieldSerializationTransformer +from zope.component import adapter +from zope.component import getMultiAdapter +from zope.interface import implementer + +import os + + +class FormSerializer: + """ """ + + order = 200 # after standard ones + block_type = "form" + + def __init__(self, context, request): + self.context = context + self.request = request + + def __call__(self, value): + """ + If user can edit the context, return the full block data. + Otherwise, skip default values because we need them only in edit and + to send emails from the backend. + """ + if "captcha" in value and value["captcha"]: + value["captcha_props"] = getMultiAdapter( + (self.context, self.request), + ICaptchaSupport, + name=value["captcha"], + ).serialize() + attachments_limit = os.environ.get("FORM_ATTACHMENTS_LIMIT", "") + if attachments_limit: + value["attachments_limit"] = attachments_limit + if api.user.has_permission("Modify portal content", obj=self.context): + return value + return {k: v for k, v in value.items() if not k.startswith("default_")} + + +@implementer(IBlockFieldSerializationTransformer) +@adapter(IBlocks, ICollectiveVoltoFormsupportLayer) +class FormSerializerContents(FormSerializer): + """Deserializer for content-types that implements IBlocks behavior""" + + +@implementer(IBlockFieldSerializationTransformer) +@adapter(IPloneSiteRoot, ICollectiveVoltoFormsupportLayer) +class FormSerializerRoot(FormSerializer): + """Deserializer for site-root""" diff --git a/backend/src/collective/volto/formsupport/restapi/serializer/configure.zcml b/backend/src/collective/volto/formsupport/restapi/serializer/configure.zcml new file mode 100644 index 0000000..860ca2a --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/serializer/configure.zcml @@ -0,0 +1,16 @@ + + + + + + diff --git a/backend/src/collective/volto/formsupport/restapi/services/__init__.py b/backend/src/collective/volto/formsupport/restapi/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/restapi/services/configure.zcml b/backend/src/collective/volto/formsupport/restapi/services/configure.zcml new file mode 100644 index 0000000..5a4cbe4 --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/configure.zcml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/backend/src/collective/volto/formsupport/restapi/services/form_data/__init__.py b/backend/src/collective/volto/formsupport/restapi/services/form_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/restapi/services/form_data/clear.py b/backend/src/collective/volto/formsupport/restapi/services/form_data/clear.py new file mode 100644 index 0000000..d37df09 --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/form_data/clear.py @@ -0,0 +1,25 @@ +from .form_data import FormData +from collective.volto.formsupport.interfaces import IFormDataStore +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from zope.component import getMultiAdapter + + +class FormDataClear(Service): + def reply(self): + form_data = json_body(self.request) + block_id = form_data.get("block_id") + expired = form_data.get("expired") + store = getMultiAdapter((self.context, self.request), IFormDataStore) + + if expired or block_id: + data = FormData(self.context, self.request, block_id=block_id) + if expired: + for item in data.get_expired_items(): + store.delete(item["id"]) + else: + for item in data.get_items(): + store.delete(item["id"]) + else: + store.clear() + return self.reply_no_content() diff --git a/backend/src/collective/volto/formsupport/restapi/services/form_data/configure.zcml b/backend/src/collective/volto/formsupport/restapi/services/form_data/configure.zcml new file mode 100644 index 0000000..797866a --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/form_data/configure.zcml @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/restapi/services/form_data/csv.py b/backend/src/collective/volto/formsupport/restapi/services/form_data/csv.py new file mode 100644 index 0000000..bf75290 --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/form_data/csv.py @@ -0,0 +1,94 @@ +from collective.volto.formsupport.interfaces import IFormDataStore +from io import StringIO +from plone.restapi.serializer.converters import json_compatible +from plone.restapi.services import Service +from zope.component import getMultiAdapter + +import csv + + +SKIP_ATTRS = ["block_id", "fields_labels", "fields_order"] + + +class FormDataExportGet(Service): + def __init__(self, context, request): + super().__init__(context, request) + self.form_fields_order = [] + self.form_block = {} + + blocks = getattr(context, "blocks", {}) + if not blocks: + return + for id, block in blocks.items(): + block_type = block.get("@type", "") + if block_type == "form": + self.form_block = block + + if self.form_block: + for field in self.form_block.get("subblocks", []): + field_id = field["field_id"] + self.form_fields_order.append(field_id) + + def get_ordered_keys(self, record): + """ + We need this method because we want to maintain the fields order set in the form. + The form can also change during time, and each record can have different fields stored in it. + """ + record_order = record.attrs.get("fields_order", []) + if record_order: + return record_order + order = [] + # first add the keys that are currently in the form + for k in self.form_fields_order: + if k in record.attrs: + order.append(k) + # finally append the keys stored in the record but that are not in the form (maybe the form changed during time) + for k in record.attrs.keys(): + if k not in order and k not in SKIP_ATTRS: + order.append(k) + return order + + def render(self): + self.check_permission() + + self.request.response.setHeader( + "Content-Disposition", + f'attachment; filename="{self.__name__}.csv"', + ) + self.request.response.setHeader("Content-Type", "text/comma-separated-values") + data = self.get_data() + if isinstance(data, str): + data = data.encode("utf-8") + self.request.response.write(data) + + def get_data(self): + store = getMultiAdapter((self.context, self.request), IFormDataStore) + sbuf = StringIO() + fixed_columns = ["date"] + columns = [] + + rows = [] + for item in store.search(): + data = {} + fields_labels = item.attrs.get("fields_labels", {}) + for k in self.get_ordered_keys(item): + if k in SKIP_ATTRS: + continue + value = item.attrs.get(k, None) + label = fields_labels.get(k, k) + if label not in columns and label not in fixed_columns: + columns.append(label) + data[label] = json_compatible(value) + for k in fixed_columns: + # add fixed columns values + value = item.attrs.get(k, None) + data[k] = json_compatible(value) + rows.append(data) + columns.extend(fixed_columns) + writer = csv.DictWriter(sbuf, fieldnames=columns, quoting=csv.QUOTE_ALL) + writer.writeheader() + for row in rows: + writer.writerow(row) + res = sbuf.getvalue() + sbuf.close() + return res diff --git a/backend/src/collective/volto/formsupport/restapi/services/form_data/form_data.py b/backend/src/collective/volto/formsupport/restapi/services/form_data/form_data.py new file mode 100644 index 0000000..43ed4f8 --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/form_data/form_data.py @@ -0,0 +1,112 @@ +from collective.volto.formsupport.interfaces import IFormDataStore +from collective.volto.formsupport.utils import get_blocks +from datetime import datetime +from datetime import timedelta +from plone import api +from plone.memoize import view +from plone.restapi.interfaces import IExpandableElement +from plone.restapi.serializer.converters import json_compatible +from plone.restapi.services import Service +from zope.component import adapter +from zope.component import getMultiAdapter +from zope.interface import implementer +from zope.interface import Interface + +import json + + +@implementer(IExpandableElement) +@adapter(Interface, Interface) +class FormData: + def __init__(self, context, request, block_id=None): + self.context = context + self.request = request + self.block_id = block_id or self.request.get("block_id") + + @view.memoize + def get_items(self): + block = self.form_block + items = [] + if block: + store = getMultiAdapter((self.context, self.request), IFormDataStore) + remove_data_after_days = int(block.get("remove_data_after_days") or 0) + data = store.search() + if remove_data_after_days > 0: + expire_date = datetime.now() - timedelta(days=remove_data_after_days) + else: + expire_date = None + for record in data: + if not self.block_id or record.attrs.get("block_id") == self.block_id: + expanded = self.expand_records(record) + expanded["__expired"] = ( + expire_date and record.attrs["date"] < expire_date + ) + items.append(expanded) + else: + items = [] + return items + + @view.memoize + def get_expired_items(self): + return [item for item in self.get_items() if item["__expired"]] + + def __call__(self, expand=False): + if not self.show_component(): + return {} + if self.block_id: + service_id = ( + f"{self.context.absolute_url()}/@form-data?block_id{self.block_id}" + ) + else: + service_id = f"{self.context.absolute_url()}/@form-data" + result = {"form_data": {"@id": service_id}} + if not expand: + return result + items = self.get_items() + expired_total = len(self.get_expired_items()) + result["form_data"] = { + "@id": f"{self.context.absolute_url()}/@form-data", + "items": items, + "items_total": len(items), + "expired_total": expired_total, + } + return result + + @property + @view.memoize + def form_block(self): + blocks = get_blocks(self.context) + if isinstance(blocks, str): + blocks = json.loads(blocks) + if not blocks: + return {} + for id, block in blocks.items(): + if block.get("@type", "") == "form" and block.get("store", False): + if not self.block_id or self.block_id == id: + return block + return {} + + def show_component(self): + if not api.user.has_permission("Modify portal content", obj=self.context): + return False + return self.form_block and True or False + + def expand_records(self, record): + fields_labels = record.attrs.get("fields_labels", {}) + data = {} + for k, v in record.attrs.items(): + if k in ["fields_labels", "fields_order"]: + continue + data[k] = { + "value": json_compatible(v), + "label": fields_labels.get(k, k), + } + data["id"] = record.intid + return data + + +class FormDataGet(Service): + def reply(self): + block_id = self.request.get("block_id") + form_data = FormData(self.context, self.request, block_id=block_id) + return form_data(expand=True).get("form_data", {}) diff --git a/backend/src/collective/volto/formsupport/restapi/services/submit_form/__init__.py b/backend/src/collective/volto/formsupport/restapi/services/submit_form/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/restapi/services/submit_form/configure.zcml b/backend/src/collective/volto/formsupport/restapi/services/submit_form/configure.zcml new file mode 100644 index 0000000..8e566ba --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/submit_form/configure.zcml @@ -0,0 +1,16 @@ + + + + + diff --git a/backend/src/collective/volto/formsupport/restapi/services/submit_form/post.py b/backend/src/collective/volto/formsupport/restapi/services/submit_form/post.py new file mode 100644 index 0000000..d124c6f --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -0,0 +1,541 @@ +from bs4 import BeautifulSoup +from collective.volto.formsupport import _ +from collective.volto.formsupport.interfaces import ICaptchaSupport +from collective.volto.formsupport.interfaces import IFormDataStore +from collective.volto.formsupport.interfaces import IPostEvent +from collective.volto.formsupport.utils import get_blocks +from collective.volto.formsupport.utils import validate_email_token +from copy import deepcopy +from datetime import datetime +from email import policy +from email.message import EmailMessage +from io import BytesIO +from plone import api + + +try: + from plone.base.interfaces.controlpanel import IMailSchema +except ImportError: + from Products.CMFPlone.interfaces.controlpanel import IMailSchema + +from plone.protect.interfaces import IDisableCSRFProtection +from plone.registry.interfaces import IRegistry +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from plone.schema.email import _isemail +from xml.etree.ElementTree import Element +from xml.etree.ElementTree import ElementTree +from xml.etree.ElementTree import SubElement +from zExceptions import BadRequest +from zope.component import getMultiAdapter +from zope.component import getUtility +from zope.event import notify +from zope.i18n import translate +from zope.interface import alsoProvides +from zope.interface import implementer + +import codecs +import logging +import math +import os +import re + + +logger = logging.getLogger(__name__) +CTE = os.environ.get("MAIL_CONTENT_TRANSFER_ENCODING", None) + + +@implementer(IPostEvent) +class PostEventService: + def __init__(self, context, data): + self.context = context + self.data = data + + +class SubmitPost(Service): + def __init__(self, context, request): + super().__init__(context, request) + + self.block = {} + self.form_data = self.cleanup_data() + self.block_id = self.form_data.get("block_id", "") + if self.block_id: + self.block = self.get_block_data(block_id=self.block_id) + + def reply(self): + self.validate_form() + + store_action = self.block.get("store", False) + send_action = self.block.get("send", []) + + # Disable CSRF protection + alsoProvides(self.request, IDisableCSRFProtection) + + notify(PostEventService(self.context, self.form_data)) + + if send_action: + try: + self.send_data() + except BadRequest as e: + raise e + except Exception as e: + logger.exception(e) + message = translate( + _( + "mail_send_exception", + default="Unable to send confirm email. Please retry later or contact site administrator.", + ), + context=self.request, + ) + self.request.response.setStatus(500) + return dict(type="InternalServerError", message=message) + if store_action: + self.store_data() + + return {"data": self.form_data.get("data", [])} + + def cleanup_data(self): + """ + Avoid XSS injections and other attacks. + + - cleanup HTML with plone transform + - remove from data, fields not defined in form schema + """ + form_data = json_body(self.request) + fixed_fields = [] + transforms = api.portal.get_tool(name="portal_transforms") + + block = self.get_block_data(block_id=form_data.get("block_id", "")) + block_fields = [x.get("field_id", "") for x in block.get("subblocks", [])] + + for form_field in form_data.get("data", []): + if form_field.get("field_id", "") not in block_fields: + # unknown field, skip it + continue + new_field = deepcopy(form_field) + value = new_field.get("value", "") + if isinstance(value, str): + stream = transforms.convertTo("text/plain", value, mimetype="text/html") + new_field["value"] = stream.getData().strip() + fixed_fields.append(new_field) + form_data["data"] = fixed_fields + return form_data + + def validate_form(self): + """ + check all required fields and parameters + """ + if not self.block_id: + raise BadRequest( + translate( + _("missing_blockid_label", default="Missing block_id"), + context=self.request, + ) + ) + if not self.block: + raise BadRequest( + translate( + _( + "block_form_not_found_label", + default='Block with @type "form" and id "$block" not found in this context: $context', + mapping={ + "block": self.block_id, + "context": self.context.absolute_url(), + }, + ), + context=self.request, + ), + ) + + if not self.block.get("store", False) and not self.block.get("send", []): + raise BadRequest( + translate( + _( + "missing_action", + default='You need to set at least one form action between "send" and "store".', # noqa + ), + context=self.request, + ) + ) + + if not self.form_data.get("data", []): + raise BadRequest( + translate( + _( + "empty_form_data", + default="Empty form data.", + ), + context=self.request, + ) + ) + + self.validate_attachments() + if self.block.get("captcha", False): + getMultiAdapter( + (self.context, self.request), + ICaptchaSupport, + name=self.block["captcha"], + ).verify(self.form_data.get("captcha")) + + self.validate_email_fields() + self.validate_bcc() + + def validate_email_fields(self): + email_fields = [ + x.get("field_id", "") + for x in self.block.get("subblocks", []) + if x.get("field_type", "") == "from" + ] + for form_field in self.form_data.get("data", []): + if form_field.get("field_id", "") not in email_fields: + continue + if _isemail(form_field.get("value", "")) is None: + raise BadRequest( + translate( + _( + "wrong_email", + default='Email not valid in "${field}" field.', + mapping={ + "field": form_field.get("label", ""), + }, + ), + context=self.request, + ) + ) + + def validate_attachments(self): + attachments_limit = os.environ.get("FORM_ATTACHMENTS_LIMIT", "") + if not attachments_limit: + return + attachments = self.form_data.get("attachments", {}) + attachments_len = 0 + for attachment in attachments.values(): + data = attachment.get("data", "") + attachments_len += (len(data) * 3) / 4 - data.count("=", -2) + if attachments_len > float(attachments_limit) * pow(1024, 2): + size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + i = int(math.floor(math.log(attachments_len, 1024))) + p = math.pow(1024, i) + s = round(attachments_len / p, 2) + uploaded_str = f"{s} {size_name[i]}" + raise BadRequest( + translate( + _( + "attachments_too_big", + default="Attachments too big. You uploaded ${uploaded_str}," + " but limit is ${max} MB. Try to compress files.", + mapping={ + "max": attachments_limit, + "uploaded_str": uploaded_str, + }, + ), + context=self.request, + ) + ) + + def validate_bcc(self): + bcc_fields = [] + for field in self.block.get("subblocks", []): + if field.get("use_as_bcc", False): + field_id = field.get("field_id", "") + if field_id not in bcc_fields: + bcc_fields.append(field_id) + + for data in self.form_data.get("data", []): + value = data.get("value", "") + if not value: + continue + + if data.get("field_id", "") in bcc_fields: + if not validate_email_token( + self.form_data.get("block_id", ""), data["value"], data["otp"] + ): + raise BadRequest( + _("{email}'s OTP is wrong").format(email=data["value"]) + ) + + def get_block_data(self, block_id): + blocks = get_blocks(self.context) + if not blocks: + return {} + for id, block in blocks.items(): + if id != block_id: + continue + block_type = block.get("@type", "") + if block_type != "form": + continue + return block + return {} + + def get_reply_to(self): + """This method retrieves the correct field to be used as 'reply to'. + + Three "levels" of logic: + 1. If there is a field marked with 'use_as_reply_to' set to True, that + field wins and we use that. + If not: + 2. We search for the "from" field. + If not present: + 3. We use the fallback field: "default_from" + """ + + subblocks = self.block.get("subblocks", "") + if subblocks: + for field in subblocks: + if field.get("use_as_reply_to", False): + field_id = field.get("field_id", "") + if field_id: + for data in self.form_data.get("data", ""): + if data.get("field_id", "") == field_id: + return data.get("value", "") + + return self.form_data.get("from", "") or self.block.get("default_from", "") + + def get_bcc(self): + bcc = [] + bcc_fields = [] + for field in self.block.get("subblocks", []): + if field.get("use_as_bcc", False): + field_id = field.get("field_id", "") + if field_id not in bcc_fields: + bcc_fields.append(field_id) + bcc = [] + for data in self.form_data.get("data", []): + value = data.get("value", "") + if not value: + continue + if data.get("field_id", "") in bcc_fields: + bcc.append(data["value"]) + return bcc + + def get_acknowledgement_field_value(self): + acknowledgementField = self.block["acknowledgementFields"] + for field in self.block.get("subblocks", []): + if field.get("field_id") == acknowledgementField: + for data in self.form_data.get("data", []): + if data.get("field_id", "") == field.get("field_id"): + return data.get("value") + + def get_subject(self): + subject = self.form_data.get("subject", "") or self.block.get( + "default_subject", "" + ) + + for i in self.form_data.get("data", []): + field_id = i.get("field_id") + + if not field_id: + continue + + # Handle this kind of id format: `field_name_123321, whichj is used by frontend package logics + pattern = r"\$\{[^}]+\}" + matches = re.findall(pattern, subject) + + for match in matches: + if field_id in match: + subject = subject.replace(match, i.get("value")) + + return subject + + def send_data(self): + subject = self.get_subject() + + mfrom = self.form_data.get("from", "") or self.block.get("default_from", "") + mreply_to = self.get_reply_to() + + if not subject or not mfrom: + raise BadRequest( + translate( + _( + "send_required_field_missing", + default="Missing required field: subject or from.", + ), + context=self.request, + ) + ) + + portal = api.portal.get() + overview_controlpanel = getMultiAdapter( + (portal, self.request), name="overview-controlpanel" + ) + if overview_controlpanel.mailhost_warning(): + raise BadRequest("MailHost is not configured.") + + registry = getUtility(IRegistry) + mail_settings = registry.forInterface(IMailSchema, prefix="plone") + charset = registry.get("plone.email_charset", "utf-8") + + should_send = self.block.get("send", []) + if should_send: + portal_transforms = api.portal.get_tool(name="portal_transforms") + mto = self.block.get("default_to", mail_settings.email_from_address) + message = self.prepare_message() + text_message = ( + portal_transforms.convertTo("text/plain", message, mimetype="text/html") + .getData() + .strip() + ) + msg = EmailMessage(policy=policy.SMTP) + msg.set_content(text_message, cte=CTE) + msg.add_alternative(message, subtype="html", cte=CTE) + msg["Subject"] = subject + msg["From"] = mfrom + msg["To"] = mto + msg["Reply-To"] = mreply_to + + headers_to_forward = self.block.get("httpHeaders", []) + for header in headers_to_forward: + header_value = self.request.get(header) + if header_value: + msg[header] = header_value + + self.manage_attachments(msg=msg) + + if isinstance(should_send, list): + if "recipient" in self.block.get("send", []): + self.send_mail(msg=msg, charset=charset) + # Backwards compatibility for forms before 'acknowledgement' sending + else: + self.send_mail(msg=msg, charset=charset) + + # send a copy also to the fields with bcc flag + for bcc in self.get_bcc(): + msg.replace_header("To", bcc) + self.send_mail(msg=msg, charset=charset) + + acknowledgement_message = self.block.get("acknowledgementMessage") + if acknowledgement_message and "acknowledgement" in self.block.get("send", []): + acknowledgement_address = self.get_acknowledgement_field_value() + if acknowledgement_address: + acknowledgement_mail = EmailMessage(policy=policy.SMTP) + acknowledgement_mail["Subject"] = subject + acknowledgement_mail["From"] = mfrom + acknowledgement_mail["To"] = acknowledgement_address + ack_msg = acknowledgement_message.get("data") + ack_msg_text = ( + portal_transforms.convertTo( + "text/plain", ack_msg, mimetype="text/html" + ) + .getData() + .strip() + ) + acknowledgement_mail.set_content(ack_msg_text, cte=CTE) + acknowledgement_mail.add_alternative(ack_msg, subtype="html", cte=CTE) + self.send_mail(msg=acknowledgement_mail, charset=charset) + + def prepare_message(self): + mail_header = self.block.get("mail_header", {}).get("data", "") + mail_footer = self.block.get("mail_footer", {}).get("data", "") + + # Check if there is content + mail_header = BeautifulSoup(mail_header).get_text() if mail_header else None + mail_footer = BeautifulSoup(mail_footer).get_text() if mail_footer else None + + email_format_page_template_mapping = { + "list": "send_mail_template", + "table": "send_mail_template_table", + } + email_format = self.block.get("email_format", "") + template_name = email_format_page_template_mapping.get( + email_format, "send_mail_template" + ) + + message_template = api.content.get_view( + name=template_name, + context=self.context, + request=self.request, + ) + parameters = { + "parameters": self.filter_parameters(), + "url": self.context.absolute_url(), + "title": self.context.Title(), + "mail_header": mail_header, + "mail_footer": mail_footer, + } + return message_template(**parameters) + + def filter_parameters(self): + """ + do not send attachments fields. + """ + skip_fields = [ + x.get("field_id", "") + for x in self.block.get("subblocks", []) + if x.get("field_type", "") == "attachment" + ] + return [ + x + for x in self.form_data.get("data", []) + if x.get("field_id", "") not in skip_fields + ] + + def send_mail(self, msg, charset): + host = api.portal.get_tool(name="MailHost") + # we set immediate=True because we need to catch exceptions. + # by default (False) exceptions are handled by MailHost and we can't catch them. + host.send(msg, charset=charset, immediate=True) + + def manage_attachments(self, msg): + attachments = self.form_data.get("attachments", {}) + + if self.block.get("attachXml", False): + self.attach_xml(msg=msg) + + if not attachments: + return [] + for key, value in attachments.items(): + content_type = "application/octet-stream" + filename = None + if isinstance(value, dict): + file_data = value.get("data", "") + if not file_data: + continue + content_type = value.get("content-type", content_type) + filename = value.get("filename", filename) + if isinstance(file_data, str): + file_data = file_data.encode("utf-8") + if "encoding" in value: + file_data = codecs.decode(file_data, value["encoding"]) + if isinstance(file_data, str): + file_data = file_data.encode("utf-8") + else: + file_data = value + maintype, subtype = content_type.split("/") + msg.add_attachment( + file_data, + maintype=maintype, + subtype=subtype, + filename=filename, + ) + + def attach_xml(self, msg): + now = ( + datetime.now() + .isoformat(timespec="seconds") + .replace(" ", "-") + .replace(":", "") + ) + filename = f"formdata_{now}.xml" + output = BytesIO() + xmlRoot = Element("form") + + for field in self.filter_parameters(): + SubElement( + xmlRoot, "field", name=field.get("custom_field_id", field["label"]) + ).text = str(field.get("value", "")) + + doc = ElementTree(xmlRoot) + doc.write(output, encoding="utf-8", xml_declaration=True) + xmlstr = output.getvalue() + msg.add_attachment( + xmlstr, + maintype="application", + subtype="xml", + filename=filename, + ) + + def store_data(self): + store = getMultiAdapter((self.context, self.request), IFormDataStore) + res = store.add(data=self.filter_parameters()) + if not res: + raise BadRequest("Unable to store data") diff --git a/backend/src/collective/volto/formsupport/restapi/services/validation/__init__.py b/backend/src/collective/volto/formsupport/restapi/services/validation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/restapi/services/validation/configure.zcml b/backend/src/collective/volto/formsupport/restapi/services/validation/configure.zcml new file mode 100644 index 0000000..5095d2c --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/validation/configure.zcml @@ -0,0 +1,24 @@ + + + + + + diff --git a/backend/src/collective/volto/formsupport/restapi/services/validation/email.py b/backend/src/collective/volto/formsupport/restapi/services/validation/email.py new file mode 100644 index 0000000..3732f96 --- /dev/null +++ b/backend/src/collective/volto/formsupport/restapi/services/validation/email.py @@ -0,0 +1,94 @@ +from collective.volto.formsupport import _ +from collective.volto.formsupport.utils import generate_email_token +from collective.volto.formsupport.utils import validate_email_token +from email import policy +from email.message import EmailMessage +from email.utils import parseaddr +from plone import api +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from zExceptions import BadRequest + +import logging +import os + + +CTE = os.environ.get("MAIL_CONTENT_TRANSFER_ENCODING", None) + +logger = logging.getLogger(__name__) + + +class ValidateEmailMessage(Service): + def reply(self): + data = self.validate() + + self.send_token(generate_email_token(data["uid"], data["email"]), data["email"]) + + return self.reply_no_content() + + def send_token(self, token, email): + """ + Send token to recipient + """ + portal_transforms = api.portal.get_tool(name="portal_transforms") + mail_view = api.content.get_view( + context=self.context, name="email-confirm-view" + ) + + content = mail_view(token=token) + mfrom = api.portal.get_registry_record("plone.email_from_address") + host = api.portal.get_tool("MailHost") + msg = EmailMessage(policy=policy.SMTP) + + msg.set_content( + portal_transforms.convertTo("text/plain", content, mimetype="text/html") + .getData() + .strip(), + cte=CTE, + ) + msg.add_alternative(content, subtype="html", cte=CTE) + + msg["Subject"] = api.portal.translate(_("Email confirmation code")) + msg["From"] = mfrom + msg["To"] = email + + try: + host.send(msg, charset="utf-8") + except Exception as e: + logger.error(f"The email confirmation message was not send due to {e}") + + def validate(self): + data = json_body(self.request) + + if "email" not in data: + raise BadRequest(_("The email field is missing")) + + if "@" not in parseaddr(data["email"])[1]: + raise BadRequest(_("The provided email address is not valid")) + + if "uid" not in data: + raise BadRequest(_("The uid field is missing")) + + return data + + +class ValidateEmailToken(Service): + def reply(self): + self.validate() + + return self.reply_no_content() + + def validate(self): + data = json_body(self.request) + + if "email" not in data: + raise BadRequest(_("The email field is missing")) + + if "otp" not in data: + raise BadRequest(_("The otp field is missing")) + + if "uid" not in data: + raise BadRequest(_("The uid field is missing")) + + if not validate_email_token(data["uid"], data["email"], data["otp"]): + raise BadRequest(_("OTP is wrong")) diff --git a/backend/src/collective/volto/formsupport/scripts/__init__.py b/backend/src/collective/volto/formsupport/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/scripts/cleansing.py b/backend/src/collective/volto/formsupport/scripts/cleansing.py new file mode 100644 index 0000000..3245e4c --- /dev/null +++ b/backend/src/collective/volto/formsupport/scripts/cleansing.py @@ -0,0 +1,70 @@ +from collective.volto.formsupport.interfaces import IFormDataStore +from collective.volto.formsupport.restapi.services.form_data.form_data import FormData +from plone import api +from zope.component import getMultiAdapter +from zope.globalrequest import getRequest + +import click +import sys +import transaction + + +@click.command( + help="bin/instance -OPlone run bin/formsupport_data_cleansing [--dryrun|--no-dryrun]", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + ), +) +@click.option( + "--dryrun/--no-dryrun", + is_flag=True, + default=True, + help="--dryrun (default) simulate, --no-dryrun actually save the changes", +) +def main(dryrun): + # import pdb;pdb.set_trace() + if dryrun: + print("CHECK ONLY") + catalog = api.portal.get_tool("portal_catalog") + root_path = "/".join(api.portal.get().getPhysicalPath()) + request = getRequest() + if "blocks_type" in catalog.indexes(): + brains = catalog.unrestrictedSearchResults(block_types="form", path=root_path) + else: + print("[WARN] This script is optimized for plone.volto >= 4.1.0") + brains = catalog.unrestrictedSearchResults(path=root_path) + for brain in brains: + obj = brain.getObject() + blocks = getattr(obj, "blocks", None) + if isinstance(blocks, dict): + for block_id, block in blocks.items(): + if block.get("@type", "") != "form": + continue + if not block.get("store", False): + continue + remove_data_after_days = int(block.get("remove_data_after_days") or 0) + # 0/None -> default value + # -1 -> don't remove + if remove_data_after_days <= 0: + print( + f"SKIP record cleanup from {brain.getPath()} block: {block_id}" + ) + continue + data = FormData(obj, request, block_id) + store = getMultiAdapter((obj, request), IFormDataStore) + deleted = 0 + for item in data.get_expired_items(): + store.delete(item["id"]) + deleted += 1 + if deleted: + print( + f"[INFO] removed {deleted} records from {brain.getPath()} block: {block_id}" + ) + if not dryrun: + print("COMMIT") + transaction.commit() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/src/collective/volto/formsupport/setuphandlers.py b/backend/src/collective/volto/formsupport/setuphandlers.py new file mode 100644 index 0000000..829cf35 --- /dev/null +++ b/backend/src/collective/volto/formsupport/setuphandlers.py @@ -0,0 +1,25 @@ +try: + from plone.base.interfaces import INonInstallable +except ImportError: + # plone 5.2 + from Products.CMFPlone.interfaces import INonInstallable +from zope.interface import implementer + + +@implementer(INonInstallable) +class HiddenProfiles: + def getNonInstallableProfiles(self): + """Hide uninstall profile from site-creation and quickinstaller.""" + return [ + "collective.volto.formsupport:uninstall", + ] + + +def post_install(context): + """Post install script""" + # Do something at the end of the installation of this package. + + +def uninstall(context): + """Uninstall script""" + # Do something at the end of the uninstallation of this package. diff --git a/backend/src/collective/volto/formsupport/testing.py b/backend/src/collective/volto/formsupport/testing.py new file mode 100644 index 0000000..38f623d --- /dev/null +++ b/backend/src/collective/volto/formsupport/testing.py @@ -0,0 +1,82 @@ +from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE +from plone.app.testing import applyProfile +from plone.app.testing import FunctionalTesting +from plone.app.testing import IntegrationTesting +from plone.app.testing import PloneSandboxLayer +from plone.app.testing import quickInstallProduct +from plone.restapi.testing import PloneRestApiDXLayer +from plone.testing import z2 + +import collective.honeypot +import collective.MockMailHost +import collective.volto.formsupport +import plone.restapi + + +class VoltoFormsupportLayer(PloneSandboxLayer): + defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) + + def setUpZope(self, app, configurationContext): + # Load any other ZCML that is required for your tests. + # The z3c.autoinclude feature is disabled in the Plone fixture base + # layer. + import plone.restapi + + self.loadZCML(package=plone.restapi) + self.loadZCML(package=collective.volto.formsupport) + + def setUpPloneSite(self, portal): + applyProfile(portal, "plone.restapi:blocks") + applyProfile(portal, "collective.volto.formsupport:default") + + # Mock the validate email tocken function + def validate_email_token_mock(*args, **kwargs): + return True + + from collective.volto.formsupport import utils + + utils.validate_email_token = validate_email_token_mock + + +VOLTO_FORMSUPPORT_FIXTURE = VoltoFormsupportLayer() + + +VOLTO_FORMSUPPORT_INTEGRATION_TESTING = IntegrationTesting( + bases=(VOLTO_FORMSUPPORT_FIXTURE,), + name="VoltoFormsupportLayer:IntegrationTesting", +) + + +VOLTO_FORMSUPPORT_FUNCTIONAL_TESTING = FunctionalTesting( + bases=(VOLTO_FORMSUPPORT_FIXTURE,), + name="VoltoFormsupportLayer:FunctionalTesting", +) + + +class VoltoFormsupportRestApiLayer(PloneRestApiDXLayer): + defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) + + def setUpZope(self, app, configurationContext): + super().setUpZope(app, configurationContext) + self.loadZCML(package=collective.MockMailHost) + self.loadZCML(package=plone.restapi) + self.loadZCML(package=collective.honeypot) + self.loadZCML(package=collective.volto.formsupport) + + def setUpPloneSite(self, portal): + applyProfile(portal, "collective.volto.formsupport:default") + applyProfile(portal, "plone.restapi:blocks") + quickInstallProduct(portal, "collective.MockMailHost") + applyProfile(portal, "collective.MockMailHost:default") + + +VOLTO_FORMSUPPORT_API_FIXTURE = VoltoFormsupportRestApiLayer() +VOLTO_FORMSUPPORT_API_INTEGRATION_TESTING = IntegrationTesting( + bases=(VOLTO_FORMSUPPORT_API_FIXTURE,), + name="VoltoFormsupportRestApiLayer:Integration", +) + +VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING = FunctionalTesting( + bases=(VOLTO_FORMSUPPORT_API_FIXTURE, z2.ZSERVER_FIXTURE), + name="VoltoFormsupportRestApiLayer:Functional", +) diff --git a/backend/src/collective/volto/formsupport/tests/__init__.py b/backend/src/collective/volto/formsupport/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/collective/volto/formsupport/tests/file.pdf b/backend/src/collective/volto/formsupport/tests/file.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ef9c79819496798012ceabadd92415c3b1923c26 GIT binary patch literal 74429 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

?R4wK8QIEP1Kg{=O1nOJ{EcekN1_8U3@io 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 z783�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 U48wH?Nr*?2Ra2&click here

keep tags

" + self.api_session.patch( + self.document_url, + json={"blocks": new_blocks}, + ) + transaction.commit() + + self.assertEqual( + self.document.blocks["form-id"]["send_message"], + "click here

keep tags

", + ) diff --git a/backend/src/collective/volto/formsupport/tests/test_event.py b/backend/src/collective/volto/formsupport/tests/test_event.py new file mode 100644 index 0000000..5e48444 --- /dev/null +++ b/backend/src/collective/volto/formsupport/tests/test_event.py @@ -0,0 +1,139 @@ +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.registry.interfaces import IRegistry +from plone.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +from zope.component import getUtility +from zope.configuration import xmlconfig + +import re +import transaction +import unittest + + +def event_handler(event): + event.data["data"].append( + {"label": "Reply", "value": "hello"}, + ) + + +class TestEvent(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + xmlconfig.string( + """ + + + + """, + context=self.layer["configurationContext"], + ) + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.mailhost = getUtility(IMailHost) + + registry = getUtility(IRegistry) + registry["plone.email_from_address"] = "site_addr@plone.com" + registry["plone.email_from_name"] = "Plone test site" + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + self.document_url = self.document.absolute_url() + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + # set default block + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + transaction.commit() + + def submit_form(self, data): + url = f"{self.document_url}/@submit-form" + response = self.api_session.post( + url, + json=data, + ) + # transaction.commit() + return response + + def test_trigger_event( + self, + ): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(self.mailhost.messages), 1) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertNotIn("To: smith@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Reply: hello", msg) diff --git a/backend/src/collective/volto/formsupport/tests/test_honeypot.py b/backend/src/collective/volto/formsupport/tests/test_honeypot.py new file mode 100644 index 0000000..6531106 --- /dev/null +++ b/backend/src/collective/volto/formsupport/tests/test_honeypot.py @@ -0,0 +1,345 @@ +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.registry.interfaces import IRegistry +from plone.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +from zope.component import getUtility + +import json +import transaction +import unittest + + +class TestHoneypot(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.mailhost = getUtility(IMailHost) + + self.registry = getUtility(IRegistry) + self.registry["plone.email_from_address"] = "site_addr@plone.com" + self.registry["plone.email_from_name"] = "Plone test site" + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + self.document_url = self.document.absolute_url() + + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + # set default block + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + transaction.commit() + + def submit_form(self, data): + url = f"{self.document_url}/@submit-form" + response = self.api_session.post( + url, + json=data, + ) + return response + + def test_honeypot_installed_but_field_not_in_form(self): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + ], + "captcha": "honeypot", + }, + } + transaction.commit() + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + ], + "block_id": "form-id", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], + "Error submitting form.", + ) + + def test_honeypot_field_in_form_empty_pass_validation(self): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + ], + "captcha": "honeypot", + }, + } + transaction.commit() + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"label": "protected_1", "value": ""}, + ], + "block_id": "form-id", + }, + ) + self.assertEqual(response.status_code, 200) + + def test_honeypot_field_in_form_compiled_fail_validation(self): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "protected_1", + "field_type": "text", + }, + ], + "captcha": "honeypot", + }, + } + transaction.commit() + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "protected_1", "label": "protected_1", "value": "foo"}, + ], + "block_id": "form-id", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], + "Error submitting form.", + ) + + def test_form_submitted_from_volto_valid(self): + """ + when you compile the form from volto, the honey field value is passed into captcha value + """ + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + ], + "captcha": "honeypot", + }, + } + transaction.commit() + captcha_token = json.dumps({"id": "protected_1", "value": "foo"}) + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + ], + "block_id": "form-id", + "captcha": { + "provider": "honey", + "token": captcha_token, + "value": "", + }, + }, + ) + + self.assertEqual(response.status_code, 200) + + def test_form_submitted_from_volto_invalid_because_missing_value(self): + """ + when you compile the form from volto, the honey field value is passed into captcha value + """ + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + ], + "captcha": "honeypot", + }, + } + transaction.commit() + captcha_token = json.dumps({"id": "protected_1", "value": "foo"}) + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + ], + "block_id": "form-id", + "captcha": { + "provider": "honey", + "token": captcha_token, + }, + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], + "Error submitting form.", + ) + + def test_form_submitted_from_volto_invalid_because_compiled(self): + """ + when you compile the form from volto, the honey field value is passed into captcha value + """ + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + ], + "captcha": "honeypot", + }, + } + transaction.commit() + captcha_token = json.dumps({"id": "protected_1", "value": "foo"}) + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + ], + "block_id": "form-id", + "captcha": { + "provider": "honey", + "token": captcha_token, + "value": "i'm a bot", + }, + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], + "Error submitting form.", + ) diff --git a/backend/src/collective/volto/formsupport/tests/test_send_action_form.py b/backend/src/collective/volto/formsupport/tests/test_send_action_form.py new file mode 100644 index 0000000..0363b7b --- /dev/null +++ b/backend/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -0,0 +1,1383 @@ +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +from collective.volto.formsupport.utils import generate_email_token +from email.parser import Parser +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.registry.interfaces import IRegistry +from plone.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +from zope.component import getUtility + +import base64 +import os +import re +import transaction +import unittest +import xml.etree.ElementTree as ET + + +class TestMailSend(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.mailhost = getUtility(IMailHost) + + registry = getUtility(IRegistry) + registry["plone.email_from_address"] = "site_addr@plone.com" + registry["plone.email_from_name"] = "Plone test site" + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + self.document_url = self.document.absolute_url() + + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + # set default block + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + + os.environ["FORM_ATTACHMENTS_LIMIT"] = "" + + transaction.commit() + + def submit_form(self, data): + url = f"{self.document_url}/@submit-form" + response = self.api_session.post( + url, + json=data, + ) + transaction.commit() + return response + + def test_email_not_send_if_block_id_is_not_given(self): + response = self.submit_form( + data={"from": "john@doe.com", "message": "Just want to say hi."}, + ) + transaction.commit() + + res = response.json() + self.assertEqual(response.status_code, 400) + self.assertEqual(res["message"], "Missing block_id") + + def test_email_not_send_if_block_id_is_incorrect_or_not_present(self): + response = self.submit_form( + data={ + "from": "john@doe.com", + "message": "Just want to say hi.", + "block_id": "unknown", + }, + ) + transaction.commit() + + res = response.json() + self.assertEqual(response.status_code, 400) + self.assertEqual( + res["message"], + 'Block with @type "form" and id "unknown" not found in this context: {}'.format( # noqa + self.document_url + ), + ) + + response = self.submit_form( + data={ + "from": "john@doe.com", + "message": "Just want to say hi.", + "block_id": "text-id", + }, + ) + transaction.commit() + + res = response.json() + self.assertEqual(response.status_code, 400) + self.assertEqual( + res["message"], + 'Block with @type "form" and id "text-id" not found in this context: {}'.format( # noqa + self.document_url + ), + ) + + def test_email_not_send_if_no_action_set(self): + response = self.submit_form( + data={"from": "john@doe.com", "block_id": "form-id"}, + ) + transaction.commit() + res = response.json() + self.assertEqual(response.status_code, 400) + self.assertEqual( + res["message"], + 'You need to set at least one form action between "send" and "store".', # noqa + ) + + def test_email_not_send_if_block_id_is_correct_but_form_data_missing( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": ["recipient"], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + res = response.json() + self.assertEqual(response.status_code, 400) + self.assertEqual( + res["message"], + "Empty form data.", + ) + + def test_email_not_send_if_block_id_is_correct_but_required_fields_missing( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "xxx", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "block_id": "form-id", + "data": [{"field_id": "xxx", "label": "foo", "value": "bar"}], + }, + ) + transaction.commit() + res = response.json() + self.assertEqual(response.status_code, 400) + self.assertEqual( + res["message"], + "Missing required field: subject or from.", + ) + + def test_email_not_send_if_all_fields_are_not_in_form_schema( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": ["recipient"], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "block_id": "form-id", + "data": [{"label": "foo", "value": "bar"}], + }, + ) + transaction.commit() + res = response.json() + self.assertEqual(response.status_code, 400) + self.assertEqual(res["message"], "Empty form data.") + + def test_email_sent_with_only_fields_from_schema( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "xxx", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "block_id": "form-id", + "subject": "test subject", + "data": [ + {"label": "foo", "value": "foo", "field_id": "xxx"}, + {"label": "bar", "value": "bar", "field_id": "yyy"}, + ], + }, + ) + transaction.commit() + res = response.json() + self.assertEqual(response.status_code, 200) + self.assertEqual( + res, {"data": [{"field_id": "xxx", "label": "foo", "value": "foo"}]} + ) + + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: test subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("foo: foo", msg) + self.assertNotIn("bar: bar", msg) + + def test_email_sent_with_site_recipient( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: test subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: John", msg) + + def test_email_sent_with_forwarded_headers( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": True, + "httpHeaders": [], + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: test subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: John", msg) + self.assertNotIn("REMOTE_ADDR", msg) + + self.document.blocks = { + "form-id": { + "@type": "form", + "send": True, + "httpHeaders": [ + "REMOTE_ADDR", + "PATH_INFO", + ], + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + + msg = self.mailhost.messages[1] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: test subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: John", msg) + self.assertIn("REMOTE_ADDR", msg) + self.assertIn("PATH_INFO", msg) + + def test_email_sent_ignore_passed_recipient( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "to": "to@spam.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: test subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: John", msg) + + def test_email_sent_with_block_recipient_if_set( + self, + ): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_to": "to@block.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: test subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: to@block.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: John", msg) + + def test_email_sent_with_block_subject_if_set_and_not_passed( + self, + ): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: block subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: John", msg) + + def test_email_with_use_as_reply_to( + self, + ): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_reply_to": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "contact", "label": "Email", "value": "smith@doe.com"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: block subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: smith@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: Smith", msg) + + def test_email_field_used_as_bcc( + self, + ): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + {"label": "Name", "value": "Smith"}, + { + "field_id": "contact", + "label": "Email", + "value": "smith@doe.com", + "otp": generate_email_token( + uid="form-id", email="smith@doe.com" + ), + }, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(self.mailhost.messages), 2) + msg = self.mailhost.messages[0] + bcc_msg = self.mailhost.messages[1] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + bcc_msg = bcc_msg.decode("utf-8") + self.assertIn("To: site_addr@plone.com", msg) + self.assertNotIn("To: smith@doe.com", msg) + self.assertNotIn("To: site_addr@plone.com", bcc_msg) + self.assertIn("To: smith@doe.com", bcc_msg) + + def test_send_attachment( + self, + ): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "test", + "field_type": "text", + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + filename = os.path.join(os.path.dirname(__file__), "file.pdf") + with open(filename, "rb") as f: + file_str = f.read() + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "test", "label": "Test", "value": "test text"}, + ], + "block_id": "form-id", + "attachments": {"foo": {"data": base64.b64encode(file_str)}}, + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(self.mailhost.messages), 1) + + def test_send_attachment_validate_size( + self, + ): + os.environ["FORM_ATTACHMENTS_LIMIT"] = "1" + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "test", + "field_type": "text", + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + filename = os.path.join(os.path.dirname(__file__), "file.pdf") + with open(filename, "rb") as f: + file_str = f.read() + # increase file dimension + file_str = file_str * 100 + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "test", "label": "Test", "value": "test text"}, + ], + "block_id": "form-id", + "attachments": {"foo": {"data": base64.b64encode(file_str)}}, + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 400) + self.assertIn( + "Attachments too big. You uploaded 7.1 MB, but limit is 1 MB", + response.json()["message"], + ) + self.assertEqual(len(self.mailhost.messages), 0) + + def test_send_only_acknowledgement(self): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["acknowledgement"], + "acknowledgementFields": "contact", + "acknowledgementMessage": { + "data": "

This message will be sent to the person filling in the form.

It is Rich Text

" + }, + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "contact", "label": "Email", "value": "smith@doe.com"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + + parsed_msg = Parser().parsestr(msg) + self.assertEqual(parsed_msg.get("from"), "john@doe.com") + self.assertEqual(parsed_msg.get("to"), "smith@doe.com") + self.assertEqual(parsed_msg.get("subject"), "block subject") + msg_body = parsed_msg.get_payload()[1].get_payload().replace("=\r\n", "") + self.assertIn( + "

This message will be sent to the person filling in the form.

", + msg_body, + ) + self.assertIn("

It is Rich Text

", msg_body) + + def test_send_recipient_and_acknowledgement(self): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient", "acknowledgement"], + "acknowledgementFields": "contact", + "acknowledgementMessage": { + "data": "

This message will be sent to the person filling in the form.

It is Rich Text

" + }, + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "contact", "label": "Email", "value": "smith@doe.com"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 200) + + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + parsed_msg = Parser().parsestr(msg) + self.assertEqual(parsed_msg.get("from"), "john@doe.com") + self.assertEqual(parsed_msg.get("to"), "site_addr@plone.com") + self.assertEqual(parsed_msg.get("subject"), "block subject") + + msg_body = parsed_msg.get_payload()[1].get_payload() + msg_body = re.sub(r"\s+", " ", msg_body) + self.assertIn("Message: just want to say hi", msg_body) + self.assertIn("Name: Smith", msg_body) + + acknowledgement_message = self.mailhost.messages[1] + if isinstance(acknowledgement_message, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + acknowledgement_message = acknowledgement_message.decode("utf-8") + + parsed_ack_msg = Parser().parsestr(acknowledgement_message) + self.assertEqual(parsed_ack_msg.get("from"), "john@doe.com") + self.assertEqual(parsed_ack_msg.get("to"), "smith@doe.com") + self.assertEqual(parsed_ack_msg.get("subject"), "block subject") + + ack_msg_body = ( + parsed_ack_msg.get_payload()[1].get_payload().replace("=\r\n", "") + ) + self.assertIn( + "

This message will be sent to the person filling in the form.

", + ack_msg_body, + ) + self.assertIn("

It is Rich Text

", ack_msg_body) + + def test_email_body_formated_as_table( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": True, + "email_format": "table", + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + subject = "test subject" + name = "John" + message = "just want to say hi" + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"field_id": "message", "label": "Message", "value": message}, + {"field_id": "name", "label": "Name", "value": name}, + ], + "subject": subject, + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg).replace(" >", ">") + + self.assertIn(f"Subject: {subject}", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + + self.assertIn("""""", msg) + self.assertIn("
", msg) + self.assertIn( + f"Form submission data for {self.document.title}", msg + ) + self.assertIn( + """Field""", + msg, + ) + self.assertIn( + """Value""", + msg, + ) + + self.assertIn( + """Name""", + msg, + ) + + self.assertIn(f'{name}', msg) + self.assertIn( + """""", + msg, + ) + self.assertIn(f'{message}', msg) + + def test_email_body_formated_as_list( + self, + ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": True, + "email_format": "list", + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: test subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: John", msg) + + def test_send_xml(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": True, + "attachXml": True, + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + form_data = [ + {"field_id": "message", "label": "Message", "value": "just want to say hi"}, + {"field_id": "name", "label": "Name", "value": "John"}, + ] + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": form_data, + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + + parsed_msgs = Parser().parsestr(msg) + # 1st index is the XML attachment + msg_contents = parsed_msgs.get_payload()[1].get_payload(decode=True) + + xml_tree = ET.fromstring(msg_contents) + for index, field in enumerate(xml_tree): + self.assertEqual(field.get("name"), form_data[index]["label"]) + self.assertEqual(field.text, form_data[index]["value"]) + + def test_submit_return_400_if_malformed_email_in_email_field( + self, + ): + """ + email fields in frontend are set as "from" field_type + """ + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "contact", "label": "Email", "value": "foo"}, + ], + "block_id": "form-id", + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], 'Email not valid in "Email" field.' + ) + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "contact", "label": "Email", "value": "foo@"}, + ], + "block_id": "form-id", + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], 'Email not valid in "Email" field.' + ) + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "contact", "label": "Email", "value": "foo@asd"}, + ], + "block_id": "form-id", + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], 'Email not valid in "Email" field.' + ) + + def test_submit_return_200_if_correct_email_in_email_field( + self, + ): + """ + email fields in frontend are set as "from" field_type + """ + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + }, + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "Smith"}, + {"field_id": "contact", "label": "Email", "value": "foo@plone.org"}, + ], + "block_id": "form-id", + }, + ) + self.assertEqual(response.status_code, 200) + + def test_submit_return_200_with_submitted_data(self): + """ + This is needed for confirm message + """ + self.document.blocks = { + "form-id": { + "@type": "form", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn("Subject: test subject", msg) + self.assertIn("From: john@doe.com", msg) + self.assertIn("To: site_addr@plone.com", msg) + self.assertIn("Reply-To: john@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Name: John", msg) + + def test_cleanup_html_in_submitted_data(self): + """ + This is needed for confirm message + """ + self.document.blocks = { + "form-id": { + "@type": "form", + "send": ["recipient"], + "subblocks": [ + { + "field_id": "message", + "field_type": "text", + }, + { + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "click here

keep tags

", + }, + { + "field_id": "name", + "label": "Name", + "value": " foo", + }, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + res = response.json() + self.assertEqual( + res, + { + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "click here keep tags", + }, + { + "field_id": "name", + "label": "Name", + "value": "alert(‘XSS’) foo", + }, + ] + }, + ) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + msg = re.sub(r"\s+", " ", msg) + self.assertIn( + "Message: click here keep tags", + msg, + ) + self.assertIn("Name: alert(=E2=80=98XSS=E2=80=99) foo", msg) diff --git a/backend/src/collective/volto/formsupport/tests/test_serialize_block.py b/backend/src/collective/volto/formsupport/tests/test_serialize_block.py new file mode 100644 index 0000000..1d89002 --- /dev/null +++ b/backend/src/collective/volto/formsupport/tests/test_serialize_block.py @@ -0,0 +1,231 @@ +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings +from plone.formwidget.recaptcha.interfaces import IReCaptchaSettings +from plone.registry.interfaces import IRegistry +from plone.restapi.testing import RelativeSession +from zope.component import getUtility + +import os +import transaction +import unittest + + +class TestBlockSerialization(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_from": "foo@foo.com", + "default_bar": "bar", + "subblocks": [ + {"field_id": "name", "label": "Name", "type": "text"}, + {"field_id": "surname", "label": "Surname", "type": "text"}, + ], + }, + } + self.document_url = self.document.absolute_url() + api.content.transition(obj=self.document, transition="publish") + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + def test_serializer_return_full_block_data_to_admin(self): + response = self.api_session.get(self.document_url) + res = response.json() + self.assertEqual(res["blocks"]["form-id"], self.document.blocks["form-id"]) + + def test_serializer_return_filtered_block_data_to_anon(self): + response = self.anon_api_session.get(self.document_url) + res = response.json() + self.assertNotEqual(res["blocks"]["form-id"], self.document.blocks["form-id"]) + self.assertNotIn("default_from", res["blocks"]["form-id"].keys()) + self.assertNotIn("default_foo", res["blocks"]["form-id"].keys()) + self.assertIn("subblocks", res["blocks"]["form-id"].keys()) + + +class TestBlockSerializationRecaptcha(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_from": "foo@foo.com", + "default_bar": "bar", + "subblocks": [ + {"field_id": "name", "label": "Name", "type": "text"}, + {"field_id": "surname", "label": "Surname", "type": "text"}, + ], + "captcha": "recaptcha", + }, + } + self.document_url = self.document.absolute_url() + api.content.transition(obj=self.document, transition="publish") + + self.registry = getUtility(IRegistry) + self.registry.registerInterface(IReCaptchaSettings) + settings = self.registry.forInterface(IReCaptchaSettings) + settings.public_key = "public" + settings.private_key = "private" + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + def test_serializer_with_recaptcha(self): + response = self.anon_api_session.get(self.document_url) + res = response.json() + self.assertEqual( + res["blocks"]["form-id"]["captcha_props"], + {"provider": "recaptcha", "public_key": "public"}, + ) + + +class TestBlockSerializationHCaptcha(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_from": "foo@foo.com", + "default_bar": "bar", + "subblocks": [ + {"field_id": "name", "label": "Name", "type": "text"}, + {"field_id": "surname", "label": "Surname", "type": "text"}, + ], + "captcha": "hcaptcha", + }, + } + self.document_url = self.document.absolute_url() + api.content.transition(obj=self.document, transition="publish") + + self.registry = getUtility(IRegistry) + self.registry.registerInterface(IHCaptchaSettings) + settings = self.registry.forInterface(IHCaptchaSettings) + settings.public_key = "public" + settings.private_key = "private" + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + def test_serializer_with_hcaptcha(self): + response = self.anon_api_session.get(self.document_url) + res = response.json() + self.assertEqual( + res["blocks"]["form-id"]["captcha_props"], + {"provider": "hcaptcha", "public_key": "public"}, + ) + + +class TestBlockSerializationAttachmentsLimit(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_from": "foo@foo.com", + "default_bar": "bar", + "subblocks": [ + {"field_id": "name", "label": "Name", "type": "text"}, + {"field_id": "surname", "label": "Surname", "type": "text"}, + ], + }, + } + self.document_url = self.document.absolute_url() + api.content.transition(obj=self.document, transition="publish") + + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + os.environ["FORM_ATTACHMENTS_LIMIT"] = "" + + def test_serializer_without_attachments_limit(self): + response = self.anon_api_session.get(self.document_url) + res = response.json() + self.assertNotIn("attachments_limit", res["blocks"]["form-id"]) diff --git a/backend/src/collective/volto/formsupport/tests/test_setup.py b/backend/src/collective/volto/formsupport/tests/test_setup.py new file mode 100644 index 0000000..a9f39fc --- /dev/null +++ b/backend/src/collective/volto/formsupport/tests/test_setup.py @@ -0,0 +1,85 @@ +"""Setup tests for this package.""" + +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_INTEGRATION_TESTING, +) +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID + +import unittest + + +try: + from plone.base.utils import get_installer +except ImportError: + from Products.CMFPlone.utils import get_installer + + +class TestSetup(unittest.TestCase): + """Test that collective.volto.formsupport is properly installed.""" + + layer = VOLTO_FORMSUPPORT_INTEGRATION_TESTING + + def setUp(self): + """Custom shared utility setup for tests.""" + self.portal = self.layer["portal"] + self.installer = get_installer(self.portal, self.layer["request"]) + + def test_product_installed(self): + """Test if collective.volto.formsupport is installed.""" + if hasattr(self.installer, "isProductInstalled"): + self.assertTrue( + self.installer.isProductInstalled("collective.volto.formsupport") + ) + else: # plone 6 + self.assertTrue( + self.installer.is_product_installed("collective.volto.formsupport") + ) + + def test_browserlayer(self): + """Test that ICollectiveVoltoFormsupportLayer is registered.""" + from collective.volto.formsupport.interfaces import ( + ICollectiveVoltoFormsupportLayer, + ) + from plone.browserlayer import utils + + self.assertIn(ICollectiveVoltoFormsupportLayer, utils.registered_layers()) + + +class TestUninstall(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + if get_installer: + self.installer = get_installer(self.portal, self.layer["request"]) + else: + self.installer = api.portal.get_tool("portal_quickinstaller") + roles_before = api.user.get_roles(TEST_USER_ID) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + if hasattr(self.installer, "uninstallProducts"): + self.installer.uninstallProducts(["collective.volto.formsupport"]) + else: # plone6 + self.installer.uninstall_product("collective.volto.formsupport") + setRoles(self.portal, TEST_USER_ID, roles_before) + + def test_product_uninstalled(self): + """Test if collective.volto.formsupport is cleanly uninstalled.""" + if hasattr(self.installer, "isProductInstalled"): + self.assertFalse( + self.installer.isProductInstalled("collective.volto.formsupport") + ) + else: # plone 6 + self.assertFalse( + self.installer.is_product_installed("collective.volto.formsupport") + ) + + def test_browserlayer_removed(self): + """Test that ICollectiveVoltoFormsupportLayer is removed.""" + from collective.volto.formsupport.interfaces import ( + ICollectiveVoltoFormsupportLayer, + ) + from plone.browserlayer import utils + + self.assertNotIn(ICollectiveVoltoFormsupportLayer, utils.registered_layers()) diff --git a/backend/src/collective/volto/formsupport/tests/test_store_action_form.py b/backend/src/collective/volto/formsupport/tests/test_store_action_form.py new file mode 100644 index 0000000..5bf076d --- /dev/null +++ b/backend/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -0,0 +1,317 @@ +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +from datetime import datetime +from io import StringIO +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +from zope.component import getUtility + +import csv +import transaction +import unittest + + +class TestMailStore(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.mailhost = getUtility(IMailHost) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + self.document_url = self.document.absolute_url() + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + # set default block + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + transaction.commit() + + def submit_form(self, data): + url = f"{self.document_url}/@submit-form" + response = self.api_session.post( + url, + json=data, + ) + transaction.commit() + return response + + def export_data(self): + url = f"{self.document_url}/@form-data" + response = self.api_session.get(url) + return response + + def export_csv(self): + url = f"{self.document_url}/@form-data-export" + response = self.api_session.get(url) + return response + + def clear_data(self): + url = f"{self.document_url}/@form-data-clear" + response = self.api_session.delete(url) + return response + + def test_unable_to_store_data(self): + """form schema not defined, unable to store data""" + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + { + "field_id": "message", + "label": "Message", + "value": "just want to say hi", + }, + {"field_id": "name", "label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["message"], "Empty form data.") + response = self.export_csv() + + def test_store_data(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + "subblocks": [ + { + "label": "Message", + "field_id": "message", + "field_type": "text", + }, + { + "label": "Name", + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"field_id": "message", "value": "just want to say hi"}, + {"field_id": "name", "value": "John"}, + {"field_id": "foo", "value": "skip this"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + response = self.export_data() + data = response.json() + self.assertEqual(len(data["items"]), 1) + self.assertEqual( + sorted(data["items"][0].keys()), + ["__expired", "block_id", "date", "id", "message", "name"], + ) + self.assertEqual( + data["items"][0]["message"], + {"label": "Message", "value": "just want to say hi"}, + ) + self.assertEqual(data["items"][0]["name"], {"label": "Name", "value": "John"}) + response = self.submit_form( + data={ + "from": "sally@doe.com", + "data": [ + {"field_id": "message", "value": "bye"}, + {"field_id": "name", "value": "Sally"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 200) + response = self.export_data() + data = response.json() + self.assertEqual(len(data["items"]), 2) + self.assertEqual( + sorted(data["items"][0].keys()), + ["__expired", "block_id", "date", "id", "message", "name"], + ) + self.assertEqual( + sorted(data["items"][1].keys()), + ["__expired", "block_id", "date", "id", "message", "name"], + ) + sorted_data = sorted(data["items"], key=lambda x: x["name"]["value"]) + self.assertEqual(sorted_data[0]["name"]["value"], "John") + self.assertEqual(sorted_data[0]["message"]["value"], "just want to say hi") + self.assertEqual(sorted_data[1]["name"]["value"], "Sally") + self.assertEqual(sorted_data[1]["message"]["value"], "bye") + + # clear data + response = self.clear_data() + self.assertEqual(response.status_code, 204) + response = self.export_csv() + data = [*csv.reader(StringIO(response.text), delimiter=",")] + self.assertEqual(len(data), 1) + self.assertEqual(data[0], ["date"]) + + def test_export_csv(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + "subblocks": [ + { + "label": "Message", + "field_id": "message", + "field_type": "text", + }, + { + "label": "Name", + "field_id": "name", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"field_id": "message", "value": "just want to say hi"}, + {"field_id": "name", "value": "John"}, + {"field_id": "foo", "value": "skip this"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + + response = self.submit_form( + data={ + "from": "sally@doe.com", + "data": [ + {"field_id": "message", "value": "bye"}, + {"field_id": "name", "value": "Sally"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + + self.assertEqual(response.status_code, 200) + response = self.export_csv() + data = [*csv.reader(StringIO(response.text), delimiter=",")] + self.assertEqual(len(data), 3) + self.assertEqual(data[0], ["Message", "Name", "date"]) + sorted_data = sorted(data[1:]) + self.assertEqual(sorted_data[0][:-1], ["bye", "Sally"]) + self.assertEqual(sorted_data[1][:-1], ["just want to say hi", "John"]) + + # check date column. Skip seconds because can change during test + now = datetime.now().strftime("%Y-%m-%dT%H:%M") + self.assertTrue(sorted_data[0][-1].startswith(now)) + self.assertTrue(sorted_data[1][-1].startswith(now)) + + def test_data_id_mapping(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + "test-field": "renamed-field", + "subblocks": [ + { + "field_id": "message", + "label": "Message", + "field_type": "text", + }, + { + "field_id": "test-field", + "label": "Test field", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"field_id": "message", "value": "just want to say hi"}, + {"field_id": "test-field", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + + response = self.submit_form( + data={ + "from": "sally@doe.com", + "data": [ + {"field_id": "message", "value": "bye"}, + {"field_id": "test-field", "value": "Sally"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + + self.assertEqual(response.status_code, 200) + response = self.export_csv() + data = [*csv.reader(StringIO(response.text), delimiter=",")] + self.assertEqual(len(data), 3) + # Check that 'test-field' got renamed + self.assertEqual(data[0], ["Message", "renamed-field", "date"]) + sorted_data = sorted(data[1:]) + self.assertEqual(sorted_data[0][:-1], ["bye", "Sally"]) + self.assertEqual(sorted_data[1][:-1], ["just want to say hi", "John"]) + + # check date column. Skip seconds because can change during test + now = datetime.now().strftime("%Y-%m-%dT%H:%M") + self.assertTrue(sorted_data[0][-1].startswith(now)) + self.assertTrue(sorted_data[1][-1].startswith(now)) diff --git a/backend/src/collective/volto/formsupport/upgrades.py b/backend/src/collective/volto/formsupport/upgrades.py new file mode 100644 index 0000000..0eb7114 --- /dev/null +++ b/backend/src/collective/volto/formsupport/upgrades.py @@ -0,0 +1,233 @@ +from Acquisition import aq_base +from collective.volto.formsupport.interfaces import IFormDataStore +from copy import deepcopy +from plone import api +from plone.dexterity.utils import iterSchemata +from plone.i18n.normalizer.interfaces import IIDNormalizer +from souper.soup import Record +from zope.component import getMultiAdapter +from zope.component import getUtility +from zope.globalrequest import getRequest +from zope.schema import getFields + + +try: + from collective.volto.blocksfield.field import BlocksField + + HAS_BLOCKSFIELD = True +except ImportError: + HAS_BLOCKSFIELD = False + +from collective.volto.formsupport import logger + +import json + + +DEFAULT_PROFILE = "profile-collective.volto.formsupport:default" + + +def _has_block_form(block_data): + for block in block_data.values(): + if block.get("@type", "") == "form": + return True + return False + + +def _get_all_content_with_blocks(): + content = [] + + portal = api.portal.get() + portal_blocks = getattr(portal, "blocks", "") + if portal_blocks: + if _has_block_form(portal_blocks): + content.append(portal) + + pc = api.portal.get_tool(name="portal_catalog") + brains = pc() + total = len(brains) + + for i, brain in enumerate(brains): + if i % 100 == 0: + logger.info(f"Progress: {i + 1}/{total}") + item = brain.getObject() + for schema in iterSchemata(item.aq_base): + for name, field in getFields(schema).items(): + if name == "blocks": + if _has_block_form(getattr(item, "blocks", {})): + content.append(item) + + return content + + +def to_1100(context): # noqa: C901 # pragma: no cover + logger.info("### START CONVERSION FORM BLOCKS ###") + + def fix_block(blocks, url): + for block in blocks.values(): + if block.get("@type", "") != "form": + continue + found = False + for field in block.get("subblocks", []): + if field.get("field_type", "") == "checkbox": + field["field_type"] = "multiple_choice" + found = True + if field.get("field_type", "") == "radio": + field["field_type"] = "simple_choice" + found = True + if found: + logger.info(f"[CONVERTED] - {url}") + + # fix root + portal = api.portal.get() + portal_blocks = getattr(portal, "blocks", "") + if portal_blocks: + blocks = json.loads(portal_blocks) + fix_block(blocks, portal.absolute_url()) + portal.blocks = json.dumps(blocks) + + # fix blocks in contents + pc = api.portal.get_tool(name="portal_catalog") + brains = pc() + tot = len(brains) + i = 0 + for brain in brains: + i += 1 + if i % 1000 == 0: + logger.info(f"Progress: {i}/{tot}") + item = aq_base(brain.getObject()) + for schema in iterSchemata(item): + for name, field in getFields(schema).items(): + if name == "blocks": + blocks = deepcopy(item.blocks) + if blocks: + fix_block(blocks, brain.getURL()) + item.blocks = blocks + elif HAS_BLOCKSFIELD and isinstance(field, BlocksField): + value = deepcopy(field.get(item)) + if not value: + continue + if isinstance(value, str): + if value == "": + setattr( + item, + name, + {"blocks": {}, "blocks_layout": {"items": []}}, + ) + continue + if blocks: + fix_block(blocks, brain.getURL()) + setattr(item, name, value) + + +def to_1200(context): # noqa: C901 # pragma: no cover + logger.info("### START CONVERSION STORED DATA ###") + + def get_field_info_from_block(block, field_id): + normalizer = getUtility(IIDNormalizer) + for field in block.get("subblocks", []): + normalized_label = normalizer.normalize(field.get("label", "")) + if field_id == normalized_label: + return {"id": field["field_id"], "label": field.get("label", "")} + elif field_id == field["field_id"]: + return {"id": field["field_id"], "label": field.get("label", "")} + return {"id": field_id, "label": field_id} + + def fix_data(blocks, context): + fixed = False + for block in blocks.values(): + if block.get("@type", "") != "form": + continue + if not block.get("store", False): + continue + store = getMultiAdapter((context, getRequest()), IFormDataStore) + fixed = True + data = store.search() + for record in data: + labels = {} + new_record = Record() + for k, v in record.attrs.items(): + new_id = get_field_info_from_block(block=block, field_id=k) + new_record.attrs[new_id["id"]] = v + labels.update({new_id["id"]: new_id["label"]}) + new_record.attrs["fields_labels"] = labels + # create new entry + store.soup.add(new_record) + # remove old one + store.delete(record.intid) + return fixed + + fixed_contents = [] + # fix root + portal = api.portal.get() + portal_blocks = getattr(portal, "blocks", "") + if portal_blocks: + blocks = json.loads(portal_blocks) + res = fix_data(blocks, portal) + if res: + fixed_contents.append("/") + + # fix blocks in contents + pc = api.portal.get_tool(name="portal_catalog") + brains = pc() + tot = len(brains) + i = 0 + for brain in brains: + i += 1 + if i % 100 == 0: + logger.info(f"Progress: {i}/{tot}") + item = brain.getObject() + for schema in iterSchemata(item.aq_base): + for name, field in getFields(schema).items(): + if name == "blocks": + blocks = getattr(item, "blocks", {}) + if blocks: + res = fix_data(blocks, item) + if res: + fixed_contents.append(brain.getPath()) + elif HAS_BLOCKSFIELD and isinstance(field, BlocksField): + value = field.get(item) + if not value: + continue + if isinstance(value, str): + continue + blocks = value.get("blocks", {}) + if blocks: + res = fix_data(blocks, item) + if res: + fixed_contents.append(brain.getPath()) + logger.info(f"Fixed {len(fixed_contents)} contents:") + for path in fixed_contents: + logger.info(f"- {path}") + + +def to_1300(context): # noqa: C901 # pragma: no cover + def update_send_from_bool_to_list_for_content(item): + blocks = ( + item.blocks + ) # We've already checked we've a form block so no need to guard here + + for block in blocks.values(): + if block.get("@type", "") != "form": + continue + send = block.get("send") + if isinstance(send, bool): + new_send_value = ["recipient"] if block.get("send") else [] + block["send"] = new_send_value + logger.info( + "[CONVERTED] - {} form send value from {} to {}".format( + item, send, new_send_value + ) + ) + + item.blocks = blocks + + logger.info("### START UPGRADE SEND FROM STRING TO ARRAY ###") + + content = _get_all_content_with_blocks() + + for item in content: + update_send_from_bool_to_list_for_content(item) + + logger.info("### FINISHED UPGRADE SEND FROM STRING TO ARRAY ###") + + # self.block.get("send") diff --git a/backend/src/collective/volto/formsupport/upgrades.zcml b/backend/src/collective/volto/formsupport/upgrades.zcml new file mode 100644 index 0000000..5943a3f --- /dev/null +++ b/backend/src/collective/volto/formsupport/upgrades.zcml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/backend/src/collective/volto/formsupport/utils.py b/backend/src/collective/volto/formsupport/utils.py new file mode 100644 index 0000000..f98e21f --- /dev/null +++ b/backend/src/collective/volto/formsupport/utils.py @@ -0,0 +1,62 @@ +from collections import deque +from plone.keyring.interfaces import IKeyManager +from zope.component import getUtility + +import base64 +import copy +import json +import pyotp + + +EMAIL_OTP_LIFETIME = 5 * 60 + + +def flatten_block_hierachy(blocks): + """Given some blocks, return all contained blocks, including "subblocks" + This allows embedding the form block into something like columns datastorage + """ + + queue = deque(list(blocks.items())) + + while queue: + blocktuple = queue.pop() + yield blocktuple + + block_value = blocktuple[1] + + if "data" in block_value: + if isinstance(block_value["data"], dict): + if "blocks" in block_value["data"]: + queue.extend(list(block_value["data"]["blocks"].items())) + + if "blocks" in block_value: + queue.extend(list(block_value["blocks"].items())) + + +def get_blocks(context): + """Returns all blocks from a context, including those coming from slots""" + + blocks = copy.deepcopy(getattr(context, "blocks", {})) + if isinstance(blocks, str): + blocks = json.loads(blocks) + + flat = list(flatten_block_hierachy(blocks)) if blocks else [] + + return dict(flat) + + +def generate_email_token(uid="", email=""): + """Generates the email verification token""" + keymanager = getUtility(IKeyManager) + + totp = pyotp.TOTP(base64.b32encode((uid + email + keymanager.secret()).encode())) + + return totp.now() + + +def validate_email_token(uid="", email="", token=""): + keymanager = getUtility(IKeyManager) + + totp = pyotp.TOTP(base64.b32encode((uid + email + keymanager.secret()).encode())) + + return totp.verify(token, valid_window=EMAIL_OTP_LIFETIME) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..9ae4db2 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,18 @@ +from collective.voltoformblock.testing import ACCEPTANCE_TESTING +from collective.voltoformblock.testing import FUNCTIONAL_TESTING +from collective.voltoformblock.testing import INTEGRATION_TESTING +from pytest_plone import fixtures_factory + + +pytest_plugins = ["pytest_plone"] + + +globals().update( + fixtures_factory( + ( + (ACCEPTANCE_TESTING, "acceptance"), + (FUNCTIONAL_TESTING, "functional"), + (INTEGRATION_TESTING, "integration"), + ) + ) +) diff --git a/backend/tests/setup/test_setup_install.py b/backend/tests/setup/test_setup_install.py new file mode 100644 index 0000000..2d2625c --- /dev/null +++ b/backend/tests/setup/test_setup_install.py @@ -0,0 +1,17 @@ +from collective.voltoformblock import PACKAGE_NAME + + +class TestSetupInstall: + def test_addon_installed(self, installer): + """Test if collective.voltoformblock is installed.""" + assert installer.is_product_installed(PACKAGE_NAME) is True + + def test_browserlayer(self, browser_layers): + """Test that IBrowserLayer is registered.""" + from collective.voltoformblock.interfaces import IBrowserLayer + + assert IBrowserLayer in browser_layers + + def test_latest_version(self, profile_last_version): + """Test latest version of default profile.""" + assert profile_last_version(f"{PACKAGE_NAME}:default") == "1000" diff --git a/backend/tests/setup/test_setup_uninstall.py b/backend/tests/setup/test_setup_uninstall.py new file mode 100644 index 0000000..8a03422 --- /dev/null +++ b/backend/tests/setup/test_setup_uninstall.py @@ -0,0 +1,19 @@ +from collective.voltoformblock import PACKAGE_NAME + +import pytest + + +class TestSetupUninstall: + @pytest.fixture(autouse=True) + def uninstalled(self, installer): + installer.uninstall_product(PACKAGE_NAME) + + def test_addon_uninstalled(self, installer): + """Test if collective.voltoformblock is uninstalled.""" + assert installer.is_product_installed(PACKAGE_NAME) is False + + def test_browserlayer_not_registered(self, browser_layers): + """Test that IBrowserLayer is not registered.""" + from collective.voltoformblock.interfaces import IBrowserLayer + + assert IBrowserLayer not in browser_layers diff --git a/backend/tox.ini b/backend/tox.ini new file mode 100644 index 0000000..04fa197 --- /dev/null +++ b/backend/tox.ini @@ -0,0 +1,211 @@ +# Generated from: +# https://github.com/plone/meta/tree/master/config/default +# See the inline comments on how to expand/tweak this configuration file +[tox] +# We need 4.4.0 for constrain_package_deps. +min_version = 4.4.0 +envlist = + lint + test + dependencies + + +## +# Add extra configuration options in .meta.toml: +# [tox] +# envlist_lines = """ +# my_other_environment +# """ +# config_lines = """ +# my_extra_top_level_tox_configuration_lines +# """ +## + +[testenv] +skip_install = true +allowlist_externals = + echo + false +# Make sure typos like `tox -e formaat` are caught instead of silently doing nothing. +# See https://github.com/tox-dev/tox/issues/2858. +commands = + echo "Unrecognized environment name {envname}" + false + +[testenv:init] +description = Prepare environment +skip_install = true +deps = + mxdev +commands = + mxdev -c mx.ini + echo "Initial setup for mxdev" + + +[testenv:format] +description = automatically reformat code +skip_install = true +deps = + pre-commit +commands = + pre-commit run -a pyupgrade + pre-commit run -a isort + pre-commit run -a black + pre-commit run -a zpretty + +[testenv:lint] +description = run linters that will help improve the code style +skip_install = true +deps = + pre-commit +commands = + pre-commit run -a + +[testenv:dependencies] +description = check if the package defines all its dependencies +skip_install = true +deps = + build + z3c.dependencychecker==2.11 +commands = + python -m build --sdist --no-isolation + dependencychecker + +[testenv:dependencies-graph] +description = generate a graph out of the dependencies of the package +skip_install = false +allowlist_externals = + sh +deps = + pipdeptree==2.5.1 + graphviz # optional dependency of pipdeptree +commands = + sh -c 'pipdeptree --exclude setuptools,wheel,pipdeptree,zope.interface,zope.component --graph-output svg > dependencies.svg' + +[testenv:test] +description = run the distribution tests +use_develop = true +skip_install = false +constrain_package_deps = true +set_env = + ROBOT_BROWSER=headlesschrome + +## +# Specify extra test environment variables in .meta.toml: +# [tox] +# test_environment_variables = """ +# PIP_EXTRA_INDEX_URL=https://my-pypi.my-server.com/ +# """ +# +# Set constrain_package_deps .meta.toml: +# [tox] +# constrain_package_deps = false +## +deps = + pytest-plone + pytest + -c https://dist.plone.org/release/6.0-dev/constraints.txt + +## +# Specify additional deps in .meta.toml: +# [tox] +# test_deps_additional = """ +# -esources/plonegovbr.portal_base[test] +# """ +# +# Specify a custom constraints file in .meta.toml: +# [tox] +# constraints_file = "https://my-server.com/constraints.txt" +## +commands = + pytest --disable-warnings {posargs} {toxinidir}/tests +extras = + test + + +[testenv:coverage] +description = get a test coverage report +use_develop = true +skip_install = false +constrain_package_deps = true +set_env = + ROBOT_BROWSER=headlesschrome + +## +# Specify extra test environment variables in .meta.toml: +# [tox] +# test_environment_variables = """ +# PIP_EXTRA_INDEX_URL=https://my-pypi.my-server.com/ +# """ +# +# Set constrain_package_deps .meta.toml: +# [tox] +# constrain_package_deps = "false" +## +deps = + pytest-plone + pytest + coverage + -c https://dist.plone.org/release/6.0-dev/constraints.txt + +commands = + coverage run --source plone.distribution -m pytest {posargs} --disable-warnings {toxinidir}/tests + coverage report -m --format markdown + coverage xml +extras = + test + + +[testenv:release-check] +description = ensure that the distribution is ready to release +skip_install = true +deps = + twine + build + towncrier + -c https://dist.plone.org/release/6.0-dev/constraints.txt + +commands = + # fake version to not have to install the package + # we build the change log as news entries might break + # the README that is displayed on PyPI + towncrier build --version=100.0.0 --yes + python -m build --sdist --no-isolation + twine check dist/* + +[testenv:circular] +description = ensure there are no cyclic dependencies +use_develop = true +skip_install = false +set_env = + +## +# Specify extra test environment variables in .meta.toml: +# [tox] +# test_environment_variables = """ +# PIP_EXTRA_INDEX_URL=https://my-pypi.my-server.com/ +# """ +## +allowlist_externals = + sh +deps = + pipdeptree + pipforester + -c https://dist.plone.org/release/6.0-dev/constraints.txt + +commands = + # Generate the full dependency tree + sh -c 'pipdeptree -j > forest.json' + # Generate a DOT graph with the circular dependencies, if any + pipforester -i forest.json -o forest.dot --cycles + # Report if there are any circular dependencies, i.e. error if there are any + pipforester -i forest.json --check-cycles -o /dev/null + + +## +# Add extra configuration options in .meta.toml: +# [tox] +# extra_lines = """ +# _your own configuration lines_ +# """ +## diff --git a/backend/version.txt b/backend/version.txt new file mode 100644 index 0000000..b619a5b --- /dev/null +++ b/backend/version.txt @@ -0,0 +1 @@ +6.0.11 diff --git a/dependabot.yml b/dependabot.yml new file mode 100644 index 0000000..df4d15b --- /dev/null +++ b/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..52dd5df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,106 @@ +--- +name: volto-form-block + +services: + traefik: + image: traefik:v2.10 + + ports: + - 80:80 + + labels: + - traefik.enable=true + - traefik.constraint-label=public + - traefik.http.routers.traefik-public-http.rule=Host(`traefik.volto-form-block.localhost`) + - traefik.http.routers.traefik-public-http.entrypoints=http + - traefik.http.routers.traefik-public-http.service=api@internal + - traefik.http.services.traefik-public.loadbalancer.server.port=8000 + + # GENERIC MIDDLEWARES + - traefik.http.middlewares.gzip.compress=true + - traefik.http.middlewares.gzip.compress.excludedcontenttypes=image/png, image/jpeg, font/woff2 + + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + + command: + - --providers.docker + - --providers.docker.constraints=Label(`traefik.constraint-label`, `public`) + - --providers.docker.exposedbydefault=false + - --entrypoints.http.address=:80 + - --accesslog + - --log + - --api + frontend: + build: + context: ./frontend + args: + - VOLTO_VERSION=${VOLTO_VERSION} + environment: + RAZZLE_INTERNAL_API_PATH: http://backend:8080/Plone + depends_on: + - backend + labels: + - traefik.enable=true + - traefik.constraint-label=public + # Service + - traefik.http.services.svc-frontend.loadbalancer.server.port=3000 + # Routers + ## / + - traefik.http.routers.rt-frontend.rule=Host(`volto-form-block.localhost`) + - traefik.http.routers.rt-frontend.entrypoints=http + - traefik.http.routers.rt-frontend.service=svc-frontend + - traefik.http.routers.rt-frontend.middlewares=gzip + + backend: + build: + context: ./backend + args: + - PLONE_VERSION=${PLONE_VERSION} + environment: + RELSTORAGE_DSN: "dbname='${DB_NAME:-plone}' user='${DB_NAME:-plone}' host='${DB_HOST:-db}' password='${DB_PASSWORD:-EUGpQKcQgBzw}' port='${DB_PORT:-5432}'" + depends_on: + - db + labels: + - traefik.enable=true + - traefik.constraint-label=public + # Services + - traefik.http.services.svc-backend.loadbalancer.server.port=8080 + + # Middlewares + ## VHM rewrite /++api++/ + - "traefik.http.middlewares.mw-backend-vhm-api.replacepathregex.regex=^/\\+\\+api\\+\\+($$|/.*)" + - "traefik.http.middlewares.mw-backend-vhm-api.replacepathregex.replacement=/VirtualHostBase/http/volto-form-block.localhost/Plone/++api++/VirtualHostRoot$$1" + + ## VHM rewrite /ClassicUI/ + - "traefik.http.middlewares.mw-backend-vhm-classic.replacepathregex.regex=^/ClassicUI($$|/.*)" + - "traefik.http.middlewares.mw-backend-vhm-classic.replacepathregex.replacement=/VirtualHostBase/http/volto-form-block.localhost/Plone/VirtualHostRoot/_vh_ClassicUI$$1" + + ## Basic Authentication + ### Note: all dollar signs in the hash need to be doubled for escaping. + ### To create user:password pair, it's possible to use this command: + ### echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g + ### Defaults to admin:admin + - traefik.http.middlewares.mw-backend-auth.basicauth.users=admin:$$apr1$$uZPT5Fgu$$AmlIdamxT5ipBvPlsdfD70 + # Routers + - traefik.http.routers.rt-backend-api.rule=Host(`volto-form-block.localhost`) && (PathPrefix(`/++api++`)) + - traefik.http.routers.rt-backend-api.entrypoints=http + - traefik.http.routers.rt-backend-api.service=svc-backend + - traefik.http.routers.rt-backend-api.middlewares=gzip,mw-backend-vhm-api + ## /ClassicUI + - traefik.http.routers.rt-backend-classic.rule=Host(`volto-form-block.localhost`) && PathPrefix(`/ClassicUI`) + - traefik.http.routers.rt-backend-classic.entrypoints=http + - traefik.http.routers.rt-backend-classic.service=svc-backend + - traefik.http.routers.rt-backend-classic.middlewares=gzip,mw-backend-auth,mw-backend-vhm-classic + + db: + image: postgres:14 + environment: + POSTGRES_USER: plone + POSTGRES_PASSWORD: EUGpQKcQgBzw + POSTGRES_DB: plone + volumes: + - vol-site-data:/var/lib/postgresql/data + +volumes: + vol-site-data: {} diff --git a/.eslintrc.js b/frontend/.eslintrc.js similarity index 100% rename from .eslintrc.js rename to frontend/.eslintrc.js diff --git a/.github/workflows/acceptance.yml b/frontend/.github/workflows/acceptance.yml similarity index 100% rename from .github/workflows/acceptance.yml rename to frontend/.github/workflows/acceptance.yml diff --git a/.github/workflows/changelog.yml b/frontend/.github/workflows/changelog.yml similarity index 100% rename from .github/workflows/changelog.yml rename to frontend/.github/workflows/changelog.yml diff --git a/.github/workflows/code.yml b/frontend/.github/workflows/code.yml similarity index 100% rename from .github/workflows/code.yml rename to frontend/.github/workflows/code.yml diff --git a/.github/workflows/i18n.yml b/frontend/.github/workflows/i18n.yml similarity index 100% rename from .github/workflows/i18n.yml rename to frontend/.github/workflows/i18n.yml diff --git a/.github/workflows/storybook.yml b/frontend/.github/workflows/storybook.yml similarity index 100% rename from .github/workflows/storybook.yml rename to frontend/.github/workflows/storybook.yml diff --git a/.github/workflows/unit.yml b/frontend/.github/workflows/unit.yml similarity index 100% rename from .github/workflows/unit.yml rename to frontend/.github/workflows/unit.yml diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..cdcd937 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,13 @@ +.*project +.settings/ +.vscode +*~ +acceptance/cypress/videos/ +acceptance/node_modules +.storybook-build +build +core +node_modules +results +yarn.lock +/public diff --git a/.npmignore b/frontend/.npmignore similarity index 100% rename from .npmignore rename to frontend/.npmignore diff --git a/.npmrc b/frontend/.npmrc similarity index 100% rename from .npmrc rename to frontend/.npmrc diff --git a/frontend/.pre-commit-config.yaml b/frontend/.pre-commit-config.yaml new file mode 100644 index 0000000..3c7d331 --- /dev/null +++ b/frontend/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: local + hooks: + - id: prettier + name: prettier + entry: pnpm exec prettier --write + language: system + files: '^packages/.*/src/.*/?.*.(js|jsx|ts|tsx)$' + types: [file] + - id: eslint + name: eslint + entry: bash -c "VOLTOCONFIG=$(pwd)/volto.config.js pnpm exec eslint --max-warnings=0 --fix" + language: system + files: '^packages/.*/src/.*/?.*.(js|jsx|ts|tsx)$' + types: [file] + - id: stylelint + name: stylelint + entry: pnpm exec stylelint --fix + language: system + files: '^packages/.*/src/.*/?.*.(css|scss|less)$' + types: [file] + - id: i18n + name: i18n + entry: make ci-i18n + language: system + files: '^packages/.*/src/.*/?.*.(js|jsx|ts|tsx)$' + types: [file] diff --git a/.prettierignore b/frontend/.prettierignore similarity index 100% rename from .prettierignore rename to frontend/.prettierignore diff --git a/.prettierrc b/frontend/.prettierrc similarity index 100% rename from .prettierrc rename to frontend/.prettierrc diff --git a/.storybook/main.js b/frontend/.storybook/main.js similarity index 100% rename from .storybook/main.js rename to frontend/.storybook/main.js diff --git a/.storybook/preview.jsx b/frontend/.storybook/preview.jsx similarity index 100% rename from .storybook/preview.jsx rename to frontend/.storybook/preview.jsx diff --git a/.stylelintrc b/frontend/.stylelintrc similarity index 100% rename from .stylelintrc rename to frontend/.stylelintrc diff --git a/frontend/Makefile b/frontend/Makefile new file mode 100644 index 0000000..afb126f --- /dev/null +++ b/frontend/Makefile @@ -0,0 +1,142 @@ +### Defensive settings for make: +# https://tech.davis-hansson.com/p/make/ +SHELL:=bash +.ONESHELL: +.SHELLFLAGS:=-eu -o pipefail -c +.SILENT: +.DELETE_ON_ERROR: +MAKEFLAGS+=--warn-undefined-variables +MAKEFLAGS+=--no-builtin-rules + +CURRENT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) + +# Recipe snippets for reuse + +# We like colors +# From: https://coderwall.com/p/izxssa/colored-makefile-for-golang-projects +RED=`tput setaf 1` +GREEN=`tput setaf 2` +RESET=`tput sgr0` +YELLOW=`tput setaf 3` + +PLONE_VERSION=6 +DOCKER_IMAGE=plone/server-dev:${PLONE_VERSION} +DOCKER_IMAGE_ACCEPTANCE=plone/server-acceptance:${PLONE_VERSION} + +ADDON_NAME='volto-form-block' +IMAGE_NAME=ghcr.io/collective/volto-form-block-frontend +IMAGE_TAG=latest +VOLTO_VERSION = $(shell cat ./mrs.developer.json | python -c "import sys, json; print(json.load(sys.stdin)['core']['tag'])") + +.PHONY: help +help: ## Show this help + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/\\x1b[36m\1\\x1b[m:\2/' | column -c2 -t -s :)" + +# Dev Helpers + +.PHONY: install +install: ## Installs the add-on in a development environment + pnpm dlx mrs-developer missdev --no-config --fetch-https + pnpm i + make build-deps + +.PHONY: start +start: ## Starts Volto, allowing reloading of the add-on during development + pnpm start + +.PHONY: build +build: ## Build a production bundle for distribution of the project with the add-on + pnpm build + +core/packages/registry/dist: core/packages/registry/src + pnpm --filter @plone/registry build + +core/packages/components/dist: core/packages/components/src + pnpm --filter @plone/components build + +.PHONY: build-deps +build-deps: core/packages/registry/dist core/packages/components/dist ## Build dependencies + +.PHONY: i18n +i18n: ## Sync i18n + pnpm --filter $(ADDON_NAME) i18n + +.PHONY: ci-i18n +ci-i18n: ## Check if i18n is not synced + pnpm --filter $(ADDON_NAME) i18n && git diff -G'^[^\"POT]' --exit-code + +.PHONY: format +format: ## Format codebase + pnpm lint:fix + pnpm prettier:fix + pnpm stylelint:fix + +.PHONY: lint +lint: ## Lint, or catch and remove problems, in code base + pnpm lint + pnpm prettier + pnpm stylelint --allow-empty-input + +.PHONY: release +release: ## Release the add-on on npmjs.org + pnpm release + +.PHONY: release-dry-run +release-dry-run: ## Dry-run the release of the add-on on npmjs.org + pnpm release + +.PHONY: test +test: ## Run unit tests + pnpm test + +.PHONY: test-ci +ci-test: ## Run unit tests in CI + # Unit Tests need the i18n to be built + VOLTOCONFIG=$(pwd)/volto.config.js pnpm --filter @plone/volto i18n + CI=1 RAZZLE_JEST_CONFIG=$(CURRENT_DIR)/jest-addon.config.js pnpm --filter @plone/volto test -- --passWithNoTests + +.PHONY: backend-docker-start +backend-docker-start: ## Starts a Docker-based backend for development + @echo "$(GREEN)==> Start Docker-based Plone Backend$(RESET)" + docker run -it --rm --name=backend -p 8080:8080 -e SITE=Plone $(DOCKER_IMAGE) + +## Storybook +.PHONY: storybook-start +storybook-start: ## Start Storybook server on port 6006 + @echo "$(GREEN)==> Start Storybook$(RESET)" + pnpm run storybook + +.PHONY: storybook-build +storybook-build: ## Build Storybook + @echo "$(GREEN)==> Build Storybook$(RESET)" + mkdir -p $(CURRENT_DIR)/.storybook-build + pnpm run storybook-build -o $(CURRENT_DIR)/.storybook-build + +## Acceptance +.PHONY: acceptance-frontend-dev-start +acceptance-frontend-dev-start: ## Start acceptance frontend in development mode + RAZZLE_API_PATH=http://127.0.0.1:55001/plone pnpm start + +.PHONY: acceptance-frontend-prod-start +acceptance-frontend-prod-start: ## Start acceptance frontend in production mode + RAZZLE_API_PATH=http://127.0.0.1:55001/plone pnpm build && pnpm start:prod + +.PHONY: acceptance-backend-start +acceptance-backend-start: ## Start backend acceptance server + docker run -it --rm -p 55001:55001 $(DOCKER_IMAGE_ACCEPTANCE) + +.PHONY: ci-acceptance-backend-start +ci-acceptance-backend-start: ## Start backend acceptance server in headless mode for CI + docker run -i --rm -p 55001:55001 $(DOCKER_IMAGE_ACCEPTANCE) + +.PHONY: acceptance-test +acceptance-test: ## Start Cypress in interactive mode + pnpm --filter @plone/volto exec cypress open --config-file $(CURRENT_DIR)/cypress.config.js --config specPattern=$(CURRENT_DIR)'/cypress/tests/**/*.{js,jsx,ts,tsx}' + +.PHONY: ci-acceptance-test +ci-acceptance-test: ## Run cypress tests in headless mode for CI + pnpm --filter @plone/volto exec cypress run --config-file $(CURRENT_DIR)/cypress.config.js --config specPattern=$(CURRENT_DIR)'/cypress/tests/**/*.{js,jsx,ts,tsx}' + +.PHONY: build-image +build-image: ## Build Docker Image + @DOCKER_BUILDKIT=1 docker build . -t $(IMAGE_NAME):$(IMAGE_TAG) -f Dockerfile --build-arg VOLTO_VERSION=$(VOLTO_VERSION) diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d53e637 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,337 @@ +# Volto Add-on (volto-form-block) + +Volto addon which adds a customizable form using a block. +Intended to be used with [collective.volto.formsupport](https://github.com/collective/collective.volto.formsupport). + +[![npm](https://img.shields.io/npm/v/volto-form-block)](https://www.npmjs.com/package/volto-form-block) +[![](https://img.shields.io/badge/-Storybook-ff4785?logo=Storybook&logoColor=white&style=flat-square)](https://collective.github.io/volto-form-block/) +[![Code analysis checks](https://github.com/collective/volto-form-block/actions/workflows/code.yml/badge.svg)](https://github.com/collective/volto-form-block/actions/workflows/code.yml) +[![Unit tests](https://github.com/collective/volto-form-block/actions/workflows/unit.yml/badge.svg)](https://github.com/collective/volto-form-block/actions/workflows/unit.yml) + +## Compatibility + +> **Note**: Since version v2.0.0 of this addon, it's required [collective.volto.formsupport](https://github.com/collective/collective.volto.formsupport) 2.0.0 or higher (and its upgrade steps). +> +> **Note**: Since version v2.1.2 of this addon, it's required Volto 14.2.0 +> +> **Note**: Since version v3.0.0 of this addon, it's required Volto >= 16.0.0-alpha.38 + +## Features + +This addon will add in your project the Form block and the needed reducers. + +Form block in chooser + +![Form block view](./docs/form-block-view.png) + +Using the engine of subblocks, you can manage form fields adding, sorting and deleting items. + +For each field, you can select the field type from: + +- Text +- Textarea +- Select +- Single choice (radio buttons) +- Multiple choice (checkbox buttons) +- Checkbox +- Date picker +- File upload with DnD +- E-mail +- Static rich text (not a fillable field, just text to display between other fields) + +For every field you can set a label and a help text. +For select, radio and checkbox fields, you can select a list of values. + +## Captcha verification + +This form addon is configured to work with [HCaptcha](https://www.hcaptcha.com), [ReCaptcha](https://www.google.com/recaptcha/) and +[NoRobot](https://github.com/collective/collective.z3cform.norobots) to prevent spam. + +In order to make one of these integrations work, you need to add +[https://github.com/plone/plone.formwidget.hcaptcha](https://github.com/plone/plone.formwidget.hcaptcha) and/or +[https://github.com/plone/plone.formwidget.recaptcha](https://github.com/plone/plone.formwidget.recaptcha) and/or +[https://github.com/collective/collective.z3cform.norobots](https://github.com/collective/collective.z3cform.norobots) +Plone addon and configure public and private keys in controlpanels. + +### HCaptcha + +With HCaptcha integration, you also have an additional option in the sidebar in 'Captcha provider' to enable or disable the invisible captcha (see implications [here](https://docs.hcaptcha.com/faq#do-i-need-to-display-anything-on-the-page-when-using-hcaptcha-in-invisible-mode)). + +In some test scenarios it's found that the "Passing Threshold" of HCaptcha must be configured as "Auto" to get the best results. In some test cases if one sets the Threshold to "Moderate" HCaptcha starts to fail. + +### OTP email validation + +To prevent sending spam emails to users via the email address configured as sender, the 'email' fields type flagged as BCC will require the user to enter an OTP code received at the address entered in the field when user fills out the form. + +## Export + +With backend support, you can store data submitted from the form. +In Edit, you can export and clear stored data from the sidebar. + +Form export + +## Additional fields + +In addition to the fields described above, you can add any field you want. +If you need a field that is not supported, PRs are always welcome, but if you have to use a custom field tailored on your project needs, then you can add additional custom fields. + +```jsx +config.blocks.blocksConfig.form.additionalFields.push({ + id: 'field type id', + label: + intl.formatMessage(messages.customFieldLabel) || + 'Label for field type select, translation obj or string', + component: MyCustomWidget, + isValid: (formData, name) => true, +}); +``` + +The widget component should have the following firm: + +```js +({ + id, + name, + title, + description, + required, + onChange, + value, + isDisabled, + invalid, +}) => ReactElement; +``` + +You should also pass a function to validate your field's data. +The `isValid` function accepts `formData` (the whole form data) and the name of the field, thus you can access to your fields' data as `formData[name]` but you also have access to other fields. + +`isValid` has the firm: + +```js +(formData, name) => boolean; +``` + +Example custom field [here](https://gist.github.com/nzambello/30949078616328e6ee0293e5b302bb40). + +## Static fields + +In backend integration, you can add in block data an object called `static_fields` and the form block will show those in form view as readonly and will aggregate those with user compiled data. + +i.e.: aggregated data from user federated authentication: + +![Static fields](./docs/form-static-fields.png) + +## Schema validators + +If you want to validate configuration field (for example, testing if 'From email' is an address of a specific domain), you could add your validation functions to block config: + +```js +config.blocks.blocksConfig.form = { + ...config.blocks.blocksConfig.form, + schemaValidators: { + fieldname: yourValidationFN(data), + }, +}; +``` + +`yourValidationFN` have to return: + +- null if field is valid +- a string with the error message if field is invalid. + +## Upgrade guide + +To upgrade to version 2.4.0 you need to: + +- remove the env vars +- install [https://github.com/plone/plone.formwidget.hcaptcha](https://github.com/plone/plone.formwidget.hcaptcha) or [https://github.com/plone/plone.formwidget.recaptcha](https://github.com/plone/plone.formwidget.recaptcha) or both in Plone. +- insert private and public keys in Plone HCaptcha controlpanel or/and Plone ReCaptcha controlpanel. + +## Video demos + +- [Form usage](https://youtu.be/v5KtjEACRmI) +- [Form editing](https://youtu.be/wmTpzYBtNCQ) +- [Export stored data](https://youtu.be/3zVUaGaaVOg) + +## VERSIONS + +With volto-form-block@2.5.0 you need to upgrade collective.volto.formsupport to version 2.4.0 + +## Installation + +To install your project, you must choose the method appropriate to your version of Volto. + + +### Volto 17 and earlier + +Create a new Volto project (you can skip this step if you already have one): + +``` +npm install -g yo @plone/generator-volto +yo @plone/volto my-volto-project --addon volto-form-block +cd my-volto-project +``` + +Add `volto-form-block` to your package.json: + +```JSON +"addons": [ + "volto-form-block" +], + +"dependencies": { + "volto-form-block": "*" +} +``` + +Download and install the new add-on by running: + +``` +yarn install +``` + +Start volto with: + +``` +yarn start +``` + +### Volto 18 and later + +Add `volto-form-block` to your add-on `package.json`: + +```json +"dependencies": { + "volto-form-block": "*" +} +``` + +## Test installation + +Visit http://localhost:3000/ in a browser, login, and check the awesome new features. + + +## Development + +The development of this add-on is done in isolation using a new approach using pnpm workspaces and latest `mrs-developer` and other Volto core improvements. +For this reason, it only works with pnpm and Volto 18 (currently in alpha). + + +### Pre-requisites + +- [Node.js](https://6.docs.plone.org/install/create-project.html#node-js) +- [Make](https://6.docs.plone.org/install/create-project.html#make) +- [Docker](https://6.docs.plone.org/install/create-project.html#docker) + + +### Make convenience commands + +Run `make help` to list the available commands. + +```text +help Show this help +install Installs the add-on in a development environment +start Starts Volto, allowing reloading of the add-on during development +build Build a production bundle for distribution of the project with the add-on +i18n Sync i18n +ci-i18n Check if i18n is not synced +format Format codebase +lint Lint, or catch and remove problems, in code base +release Release the add-on on npmjs.org +release-dry-run Dry-run the release of the add-on on npmjs.org +test Run unit tests +ci-test Run unit tests in CI +backend-docker-start Starts a Docker-based backend for development +storybook-start Start Storybook server on port 6006 +storybook-build Build Storybook +acceptance-frontend-dev-start Start acceptance frontend in development mode +acceptance-frontend-prod-start Start acceptance frontend in production mode +acceptance-backend-start Start backend acceptance server +ci-acceptance-backend-start Start backend acceptance server in headless mode for CI +acceptance-test Start Cypress in interactive mode +ci-acceptance-test Run cypress tests in headless mode for CI +``` + +### Development environment set up + +Install package requirements. + +```shell +make install +``` + +### Start developing + +Start the backend. + +```shell +make backend-docker-start +``` + +In a separate terminal session, start the frontend. + +```shell +make start +``` + +### Lint code + +Run ESlint, Prettier, and Stylelint in analyze mode. + +```shell +make lint +``` + +### Format code + +Run ESlint, Prettier, and Stylelint in fix mode. + +```shell +make format +``` + +### i18n + +Extract the i18n messages to locales. + +```shell +make i18n +``` + +### Unit tests + +Run unit tests. + +```shell +make test +``` + +### Run Cypress tests + +Run each of these steps in separate terminal sessions. + +In the first session, start the frontend in development mode. + +```shell +make acceptance-frontend-dev-start +``` + +In the second session, start the backend acceptance server. + +```shell +make acceptance-backend-start +``` + +In the third session, start the Cypress interactive test runner. + +```shell +make acceptance-test +``` + +## License + +The project is licensed under the MIT license. + +## Credits and Acknowledgements 🙏 + +Crafted with care by **Generated using [Cookieplone (0.7.1)](https://github.com/plone/cookieplone) and [cookiecutter-plone (aee0d59)](https://github.com/plone/cookiecutter-plone/commit/aee0d59c18bd0dd8af1da9c961014ff87a66ccfa) on 2024-07-04 10:49:50.444730**. A special thanks to all contributors and supporters! diff --git a/cypress.config.js b/frontend/cypress.config.js similarity index 100% rename from cypress.config.js rename to frontend/cypress.config.js diff --git a/frontend/cypress/.gitkeep b/frontend/cypress/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cypress/support/commands.js b/frontend/cypress/support/commands.js similarity index 100% rename from cypress/support/commands.js rename to frontend/cypress/support/commands.js diff --git a/cypress/support/e2e.js b/frontend/cypress/support/e2e.js similarity index 100% rename from cypress/support/e2e.js rename to frontend/cypress/support/e2e.js diff --git a/frontend/cypress/tests/.gitkeep b/frontend/cypress/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cypress/tests/example.cy.js b/frontend/cypress/tests/example.cy.js similarity index 100% rename from cypress/tests/example.cy.js rename to frontend/cypress/tests/example.cy.js diff --git a/jest-addon.config.js b/frontend/jest-addon.config.js similarity index 100% rename from jest-addon.config.js rename to frontend/jest-addon.config.js diff --git a/mrs.developer.json b/frontend/mrs.developer.json similarity index 100% rename from mrs.developer.json rename to frontend/mrs.developer.json diff --git a/package.json b/frontend/package.json similarity index 100% rename from package.json rename to frontend/package.json diff --git a/packages/volto-form-block/.gitignore b/frontend/packages/volto-form-block/.gitignore similarity index 100% rename from packages/volto-form-block/.gitignore rename to frontend/packages/volto-form-block/.gitignore diff --git a/packages/volto-form-block/.release-it.json b/frontend/packages/volto-form-block/.release-it.json similarity index 100% rename from packages/volto-form-block/.release-it.json rename to frontend/packages/volto-form-block/.release-it.json diff --git a/packages/volto-form-block/CHANGELOG.md b/frontend/packages/volto-form-block/CHANGELOG.md similarity index 100% rename from packages/volto-form-block/CHANGELOG.md rename to frontend/packages/volto-form-block/CHANGELOG.md diff --git a/packages/volto-form-block/babel.config.js b/frontend/packages/volto-form-block/babel.config.js similarity index 100% rename from packages/volto-form-block/babel.config.js rename to frontend/packages/volto-form-block/babel.config.js diff --git a/packages/volto-form-block/locales/de/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/de/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/de/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/de/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/en/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/en/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/en/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/en/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/es/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/es/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/es/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/es/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/eu/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/eu/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/eu/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/eu/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/fr/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/fr/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/fr/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/fr/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/it/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/it/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/it/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/it/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/ja/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/ja/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/ja/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/ja/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/nl/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/nl/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/nl/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/nl/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/pt/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/pt/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/pt/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/pt/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/pt_BR/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/pt_BR/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/pt_BR/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/pt_BR/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/ro/LC_MESSAGES/volto.po b/frontend/packages/volto-form-block/locales/ro/LC_MESSAGES/volto.po similarity index 100% rename from packages/volto-form-block/locales/ro/LC_MESSAGES/volto.po rename to frontend/packages/volto-form-block/locales/ro/LC_MESSAGES/volto.po diff --git a/packages/volto-form-block/locales/volto.pot b/frontend/packages/volto-form-block/locales/volto.pot similarity index 100% rename from packages/volto-form-block/locales/volto.pot rename to frontend/packages/volto-form-block/locales/volto.pot diff --git a/frontend/packages/volto-form-block/news/.gitkeep b/frontend/packages/volto-form-block/news/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/volto-form-block/news/109.internal b/frontend/packages/volto-form-block/news/109.internal similarity index 100% rename from packages/volto-form-block/news/109.internal rename to frontend/packages/volto-form-block/news/109.internal diff --git a/packages/volto-form-block/package.json b/frontend/packages/volto-form-block/package.json similarity index 100% rename from packages/volto-form-block/package.json rename to frontend/packages/volto-form-block/package.json diff --git a/frontend/packages/volto-form-block/public/.gitkeep b/frontend/packages/volto-form-block/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/volto-form-block/src/actions/index.js b/frontend/packages/volto-form-block/src/actions/index.js similarity index 100% rename from packages/volto-form-block/src/actions/index.js rename to frontend/packages/volto-form-block/src/actions/index.js diff --git a/packages/volto-form-block/src/components/Edit.jsx b/frontend/packages/volto-form-block/src/components/Edit.jsx similarity index 100% rename from packages/volto-form-block/src/components/Edit.jsx rename to frontend/packages/volto-form-block/src/components/Edit.jsx diff --git a/packages/volto-form-block/src/components/EditBlock.jsx b/frontend/packages/volto-form-block/src/components/EditBlock.jsx similarity index 100% rename from packages/volto-form-block/src/components/EditBlock.jsx rename to frontend/packages/volto-form-block/src/components/EditBlock.jsx diff --git a/packages/volto-form-block/src/components/Field.css b/frontend/packages/volto-form-block/src/components/Field.css similarity index 100% rename from packages/volto-form-block/src/components/Field.css rename to frontend/packages/volto-form-block/src/components/Field.css diff --git a/packages/volto-form-block/src/components/Field.jsx b/frontend/packages/volto-form-block/src/components/Field.jsx similarity index 100% rename from packages/volto-form-block/src/components/Field.jsx rename to frontend/packages/volto-form-block/src/components/Field.jsx diff --git a/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/FromSchemaExtender.js b/frontend/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/FromSchemaExtender.js similarity index 100% rename from packages/volto-form-block/src/components/FieldTypeSchemaExtenders/FromSchemaExtender.js rename to frontend/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/FromSchemaExtender.js diff --git a/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js b/frontend/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js similarity index 100% rename from packages/volto-form-block/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js rename to frontend/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/HiddenSchemaExtender.js diff --git a/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js b/frontend/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js similarity index 100% rename from packages/volto-form-block/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js rename to frontend/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/SelectionSchemaExtender.js diff --git a/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/index.js b/frontend/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/index.js similarity index 100% rename from packages/volto-form-block/src/components/FieldTypeSchemaExtenders/index.js rename to frontend/packages/volto-form-block/src/components/FieldTypeSchemaExtenders/index.js diff --git a/packages/volto-form-block/src/components/FormResult.jsx b/frontend/packages/volto-form-block/src/components/FormResult.jsx similarity index 100% rename from packages/volto-form-block/src/components/FormResult.jsx rename to frontend/packages/volto-form-block/src/components/FormResult.jsx diff --git a/packages/volto-form-block/src/components/FormView.css b/frontend/packages/volto-form-block/src/components/FormView.css similarity index 100% rename from packages/volto-form-block/src/components/FormView.css rename to frontend/packages/volto-form-block/src/components/FormView.css diff --git a/packages/volto-form-block/src/components/FormView.jsx b/frontend/packages/volto-form-block/src/components/FormView.jsx similarity index 100% rename from packages/volto-form-block/src/components/FormView.jsx rename to frontend/packages/volto-form-block/src/components/FormView.jsx diff --git a/packages/volto-form-block/src/components/Sidebar.css b/frontend/packages/volto-form-block/src/components/Sidebar.css similarity index 100% rename from packages/volto-form-block/src/components/Sidebar.css rename to frontend/packages/volto-form-block/src/components/Sidebar.css diff --git a/packages/volto-form-block/src/components/Sidebar.jsx b/frontend/packages/volto-form-block/src/components/Sidebar.jsx similarity index 100% rename from packages/volto-form-block/src/components/Sidebar.jsx rename to frontend/packages/volto-form-block/src/components/Sidebar.jsx diff --git a/packages/volto-form-block/src/components/ValidateConfigForm.jsx b/frontend/packages/volto-form-block/src/components/ValidateConfigForm.jsx similarity index 100% rename from packages/volto-form-block/src/components/ValidateConfigForm.jsx rename to frontend/packages/volto-form-block/src/components/ValidateConfigForm.jsx diff --git a/packages/volto-form-block/src/components/View.jsx b/frontend/packages/volto-form-block/src/components/View.jsx similarity index 100% rename from packages/volto-form-block/src/components/View.jsx rename to frontend/packages/volto-form-block/src/components/View.jsx diff --git a/packages/volto-form-block/src/components/Widget/Button.jsx b/frontend/packages/volto-form-block/src/components/Widget/Button.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/Button.jsx rename to frontend/packages/volto-form-block/src/components/Widget/Button.jsx diff --git a/packages/volto-form-block/src/components/Widget/Captcha.jsx b/frontend/packages/volto-form-block/src/components/Widget/Captcha.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/Captcha.jsx rename to frontend/packages/volto-form-block/src/components/Widget/Captcha.jsx diff --git a/packages/volto-form-block/src/components/Widget/CheckboxListWidget.css b/frontend/packages/volto-form-block/src/components/Widget/CheckboxListWidget.css similarity index 100% rename from packages/volto-form-block/src/components/Widget/CheckboxListWidget.css rename to frontend/packages/volto-form-block/src/components/Widget/CheckboxListWidget.css diff --git a/packages/volto-form-block/src/components/Widget/CheckboxListWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/CheckboxListWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/CheckboxListWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/CheckboxListWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/CheckboxWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/CheckboxWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/CheckboxWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/CheckboxWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/DatetimeWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/DatetimeWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/DatetimeWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/DatetimeWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/EmailWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/EmailWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/EmailWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/EmailWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/FileWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/FileWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/FileWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/FileWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/GoogleReCaptchaWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/GoogleReCaptchaWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/GoogleReCaptchaWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/GoogleReCaptchaWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/HCaptchaWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/HCaptchaWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/HCaptchaWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/HCaptchaWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/HiddenWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/HiddenWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/HiddenWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/HiddenWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.css b/frontend/packages/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.css similarity index 100% rename from packages/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.css rename to frontend/packages/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.css diff --git a/packages/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/HoneypotCaptchaWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/NoRobotsCaptchaWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/NoRobotsCaptchaWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/NoRobotsCaptchaWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/NoRobotsCaptchaWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/OTPWidget.css b/frontend/packages/volto-form-block/src/components/Widget/OTPWidget.css similarity index 100% rename from packages/volto-form-block/src/components/Widget/OTPWidget.css rename to frontend/packages/volto-form-block/src/components/Widget/OTPWidget.css diff --git a/packages/volto-form-block/src/components/Widget/OTPWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/OTPWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/OTPWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/OTPWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/RadioWidget.css b/frontend/packages/volto-form-block/src/components/Widget/RadioWidget.css similarity index 100% rename from packages/volto-form-block/src/components/Widget/RadioWidget.css rename to frontend/packages/volto-form-block/src/components/Widget/RadioWidget.css diff --git a/packages/volto-form-block/src/components/Widget/RadioWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/RadioWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/RadioWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/RadioWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/SelectWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/SelectWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/SelectWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/SelectWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/TextWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/TextWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/TextWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/TextWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/TextareaWidget.jsx b/frontend/packages/volto-form-block/src/components/Widget/TextareaWidget.jsx similarity index 100% rename from packages/volto-form-block/src/components/Widget/TextareaWidget.jsx rename to frontend/packages/volto-form-block/src/components/Widget/TextareaWidget.jsx diff --git a/packages/volto-form-block/src/components/Widget/index.js b/frontend/packages/volto-form-block/src/components/Widget/index.js similarity index 100% rename from packages/volto-form-block/src/components/Widget/index.js rename to frontend/packages/volto-form-block/src/components/Widget/index.js diff --git a/packages/volto-form-block/src/components/index.js b/frontend/packages/volto-form-block/src/components/index.js similarity index 100% rename from packages/volto-form-block/src/components/index.js rename to frontend/packages/volto-form-block/src/components/index.js diff --git a/packages/volto-form-block/src/components/utils.js b/frontend/packages/volto-form-block/src/components/utils.js similarity index 100% rename from packages/volto-form-block/src/components/utils.js rename to frontend/packages/volto-form-block/src/components/utils.js diff --git a/packages/volto-form-block/src/fieldSchema.js b/frontend/packages/volto-form-block/src/fieldSchema.js similarity index 100% rename from packages/volto-form-block/src/fieldSchema.js rename to frontend/packages/volto-form-block/src/fieldSchema.js diff --git a/packages/volto-form-block/src/formSchema.js b/frontend/packages/volto-form-block/src/formSchema.js similarity index 100% rename from packages/volto-form-block/src/formSchema.js rename to frontend/packages/volto-form-block/src/formSchema.js diff --git a/packages/volto-form-block/src/helpers/react-select.js b/frontend/packages/volto-form-block/src/helpers/react-select.js similarity index 100% rename from packages/volto-form-block/src/helpers/react-select.js rename to frontend/packages/volto-form-block/src/helpers/react-select.js diff --git a/packages/volto-form-block/src/helpers/validators.js b/frontend/packages/volto-form-block/src/helpers/validators.js similarity index 100% rename from packages/volto-form-block/src/helpers/validators.js rename to frontend/packages/volto-form-block/src/helpers/validators.js diff --git a/packages/volto-form-block/src/index.js b/frontend/packages/volto-form-block/src/index.js similarity index 100% rename from packages/volto-form-block/src/index.js rename to frontend/packages/volto-form-block/src/index.js diff --git a/packages/volto-form-block/src/reducers/index.js b/frontend/packages/volto-form-block/src/reducers/index.js similarity index 100% rename from packages/volto-form-block/src/reducers/index.js rename to frontend/packages/volto-form-block/src/reducers/index.js diff --git a/packages/volto-form-block/towncrier.toml b/frontend/packages/volto-form-block/towncrier.toml similarity index 100% rename from packages/volto-form-block/towncrier.toml rename to frontend/packages/volto-form-block/towncrier.toml diff --git a/packages/volto-form-block/tsconfig.json b/frontend/packages/volto-form-block/tsconfig.json similarity index 100% rename from packages/volto-form-block/tsconfig.json rename to frontend/packages/volto-form-block/tsconfig.json diff --git a/pnpm-lock.yaml b/frontend/pnpm-lock.yaml similarity index 98% rename from pnpm-lock.yaml rename to frontend/pnpm-lock.yaml index 4eaeab3..fcaa21f 100644 --- a/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -51,7 +51,7 @@ importers: version: 18.2.12 parcel: specifier: ^2.12.0 - version: 2.12.0(@swc/helpers@0.5.11)(postcss@8.4.39)(relateurl@0.2.7)(terser@5.31.1)(typescript@5.4.2) + version: 2.12.0(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.4.2) release-it: specifier: 17.1.1 version: 17.1.1(typescript@5.4.2) @@ -416,10 +416,10 @@ importers: devDependencies: '@parcel/packager-ts': specifier: 2.12.0 - version: 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + version: 2.12.0(@swc/helpers@0.5.11) '@parcel/transformer-typescript-types': specifier: 2.12.0 - version: 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(typescript@5.4.2) + version: 2.12.0(@swc/helpers@0.5.11)(typescript@5.4.2) '@plone/types': specifier: workspace:* version: link:../types @@ -431,7 +431,7 @@ importers: version: 18.2.12 parcel: specifier: 2.12.0 - version: 2.12.0(@swc/helpers@0.5.11)(postcss@8.4.39)(relateurl@0.2.7)(terser@5.31.1)(typescript@5.4.2) + version: 2.12.0(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.4.2) release-it: specifier: ^17.1.1 version: 17.1.1(typescript@5.4.2) @@ -523,10 +523,10 @@ importers: devDependencies: '@parcel/packager-ts': specifier: ^2.12.0 - version: 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + version: 2.12.0(@swc/helpers@0.5.11) '@parcel/transformer-typescript-types': specifier: ^2.12.0 - version: 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(typescript@5.4.2) + version: 2.12.0(@swc/helpers@0.5.11)(typescript@5.4.2) '@plone/types': specifier: workspace:* version: link:../types @@ -538,7 +538,7 @@ importers: version: 18.2.12 parcel: specifier: ^2.12.0 - version: 2.12.0(@swc/helpers@0.5.11)(postcss@8.4.39)(relateurl@0.2.7)(terser@5.31.1)(typescript@5.4.2) + version: 2.12.0(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.4.2) react: specifier: ^18.2.0 version: 18.2.0 @@ -627,10 +627,10 @@ importers: devDependencies: '@parcel/packager-ts': specifier: ^2.12.0 - version: 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + version: 2.12.0(@swc/helpers@0.5.11) '@parcel/transformer-typescript-types': specifier: ^2.12.0 - version: 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(typescript@5.2.2) + version: 2.12.0(@swc/helpers@0.5.11)(typescript@5.2.2) '@plone/types': specifier: workspace:* version: link:../types @@ -642,7 +642,7 @@ importers: version: 18.2.12 parcel: specifier: ^2.12.0 - version: 2.12.0(@swc/helpers@0.5.11)(postcss@8.4.39)(relateurl@0.2.7)(terser@5.31.1)(typescript@5.2.2) + version: 2.12.0(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.2.2) release-it: specifier: 17.1.1 version: 17.1.1(typescript@5.2.2) @@ -1020,7 +1020,7 @@ importers: version: 5.13.2(@babel/core@7.24.7) '@loadable/webpack-plugin': specifier: 5.15.2 - version: 5.15.2(webpack@5.90.1(esbuild@0.20.2)) + version: 5.15.2(webpack@5.90.1) '@plone/types': specifier: workspace:* version: link:../types @@ -1047,13 +1047,13 @@ importers: version: 8.1.11(react@18.2.0) '@storybook/addon-webpack5-compiler-babel': specifier: ^3.0.3 - version: 3.0.3(webpack@5.90.1(esbuild@0.20.2)) + version: 3.0.3(webpack@5.90.1) '@storybook/react': specifier: ^8.0.4 version: 8.1.11(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3) '@storybook/react-webpack5': specifier: ^8.0.4 - version: 8.1.11(encoding@0.1.13)(esbuild@0.20.2)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3) + version: 8.1.11(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3) '@storybook/theming': specifier: ^8.0.4 version: 8.1.11(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1110,7 +1110,7 @@ importers: version: 4.4.2 babel-loader: specifier: 9.1.0 - version: 9.1.0(@babel/core@7.24.7)(webpack@5.90.1(esbuild@0.20.2)) + version: 9.1.0(@babel/core@7.24.7)(webpack@5.90.1) babel-plugin-add-module-exports: specifier: 0.2.1 version: 0.2.1 @@ -1131,10 +1131,10 @@ importers: version: 0.3.3(debug@4.3.2) circular-dependency-plugin: specifier: 5.2.2 - version: 5.2.2(webpack@5.90.1(esbuild@0.20.2)) + version: 5.2.2(webpack@5.90.1) css-loader: specifier: 5.2.7 - version: 5.2.7(webpack@5.90.1(esbuild@0.20.2)) + version: 5.2.7(webpack@5.90.1) cypress: specifier: 13.6.6 version: 13.6.6 @@ -1185,7 +1185,7 @@ importers: version: 1.4.0 html-webpack-plugin: specifier: 5.5.0 - version: 5.5.0(webpack@5.90.1(esbuild@0.20.2)) + version: 5.5.0(webpack@5.90.1) identity-obj-proxy: specifier: 3.0.0 version: 3.0.0 @@ -1209,16 +1209,16 @@ importers: version: 3.11.1 less-loader: specifier: 11.1.0 - version: 11.1.0(less@3.11.1)(webpack@5.90.1(esbuild@0.20.2)) + version: 11.1.0(less@3.11.1)(webpack@5.90.1) lodash-webpack-plugin: specifier: 0.11.6 - version: 0.11.6(webpack@5.90.1(esbuild@0.20.2)) + version: 0.11.6(webpack@5.90.1) mini-css-extract-plugin: specifier: 2.7.2 - version: 2.7.2(webpack@5.90.1(esbuild@0.20.2)) + version: 2.7.2(webpack@5.90.1) moment-locales-webpack-plugin: specifier: 1.2.0 - version: 1.2.0(moment@2.29.4)(webpack@5.90.1(esbuild@0.20.2)) + version: 1.2.0(moment@2.29.4)(webpack@5.90.1) postcss: specifier: 8.4.31 version: 8.4.31 @@ -1233,7 +1233,7 @@ importers: version: 3.1.4(postcss@8.4.31) postcss-loader: specifier: 7.0.2 - version: 7.0.2(postcss@8.4.31)(webpack@5.90.1(esbuild@0.20.2)) + version: 7.0.2(postcss@8.4.31)(webpack@5.90.1) postcss-overrides: specifier: 3.1.4 version: 3.1.4 @@ -1245,16 +1245,16 @@ importers: version: 3.2.5 razzle: specifier: 4.2.18 - version: 4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1(esbuild@0.20.2)))(mini-css-extract-plugin@2.7.2(webpack@5.90.1(esbuild@0.20.2)))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack-hot-middleware@2.26.1)(webpack@5.90.1(esbuild@0.20.2)) + version: 4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1))(mini-css-extract-plugin@2.7.2(webpack@5.90.1))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack-hot-middleware@2.26.1)(webpack@5.90.1) razzle-dev-utils: specifier: 4.2.18 - version: 4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)) + version: 4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1) razzle-plugin-scss: specifier: 4.2.18 - version: 4.2.18(mini-css-extract-plugin@2.7.2(webpack@5.90.1(esbuild@0.20.2)))(postcss@8.4.31)(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)))(razzle@4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1(esbuild@0.20.2)))(mini-css-extract-plugin@2.7.2(webpack@5.90.1(esbuild@0.20.2)))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack-hot-middleware@2.26.1)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)) + version: 4.2.18(mini-css-extract-plugin@2.7.2(webpack@5.90.1))(postcss@8.4.31)(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1))(razzle@4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1))(mini-css-extract-plugin@2.7.2(webpack@5.90.1))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack-hot-middleware@2.26.1)(webpack@5.90.1))(webpack@5.90.1) react-docgen-typescript-plugin: specifier: ^1.0.5 - version: 1.0.8(typescript@5.5.3)(webpack@5.90.1(esbuild@0.20.2)) + version: 1.0.8(typescript@5.5.3)(webpack@5.90.1) react-error-overlay: specifier: 6.0.9 version: 6.0.9 @@ -1275,7 +1275,7 @@ importers: version: 8.1.11(@babel/preset-env@7.24.7(@babel/core@7.24.7))(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) style-loader: specifier: 3.3.1 - version: 3.3.1(webpack@5.90.1(esbuild@0.20.2)) + version: 3.3.1(webpack@5.90.1) stylelint: specifier: ^16.3.1 version: 16.6.1(typescript@5.5.3) @@ -1293,7 +1293,7 @@ importers: version: 3.0.3 terser-webpack-plugin: specifier: 5.3.6 - version: 5.3.6(esbuild@0.20.2)(webpack@5.90.1(esbuild@0.20.2)) + version: 5.3.6(webpack@5.90.1) tmp: specifier: 0.2.1 version: 0.2.1 @@ -1302,7 +1302,7 @@ importers: version: 26.5.6(jest@26.6.3)(typescript@5.5.3) ts-loader: specifier: 9.4.4 - version: 9.4.4(typescript@5.5.3)(webpack@5.90.1(esbuild@0.20.2)) + version: 9.4.4(typescript@5.5.3)(webpack@5.90.1) typescript: specifier: ^5.4.2 version: 5.5.3 @@ -1314,13 +1314,13 @@ importers: version: 6.0.0(debug@4.3.2) webpack: specifier: 5.90.1 - version: 5.90.1(esbuild@0.20.2) + version: 5.90.1 webpack-bundle-analyzer: specifier: 4.10.1 version: 4.10.1 webpack-dev-server: specifier: 4.11.1 - version: 4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)) + version: 4.11.1(debug@4.3.2)(webpack@5.90.1) webpack-node-externals: specifier: 3.0.0 version: 3.0.0 @@ -16906,7 +16906,9 @@ snapshots: source-map: 0.6.1 string-length: 2.0.0 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate '@jest/reporters@26.6.2': dependencies: @@ -17174,10 +17176,10 @@ snapshots: lodash: 4.17.21 react: 18.2.0 - '@loadable/webpack-plugin@5.15.2(webpack@5.90.1(esbuild@0.20.2))': + '@loadable/webpack-plugin@5.15.2(webpack@5.90.1)': dependencies: make-dir: 3.1.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 '@mdx-js/react@3.0.1(@types/react@18.2.27)(react@18.2.0)': dependencies: @@ -17669,6 +17671,15 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@parcel/cache@2.12.0(@swc/helpers@0.5.11)': + dependencies: + '@parcel/fs': 2.12.0(@swc/helpers@0.5.11) + '@parcel/logger': 2.12.0 + '@parcel/utils': 2.12.0 + lmdb: 2.8.5 + transitivePeerDependencies: + - '@swc/helpers' + '@parcel/codeframe@2.12.0': dependencies: chalk: 4.1.2 @@ -17815,6 +17826,96 @@ snapshots: - typescript - uncss + '@parcel/config-default@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.2.2)': + dependencies: + '@parcel/bundler-default': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/compressor-raw': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/core': 2.12.0(@swc/helpers@0.5.11) + '@parcel/namer-default': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/optimizer-css': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/optimizer-htmlnano': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(postcss@8.4.39)(relateurl@0.2.7)(terser@5.31.1)(typescript@5.2.2) + '@parcel/optimizer-image': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/optimizer-svgo': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/optimizer-swc': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-css': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-html': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-js': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-raw': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-svg': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-wasm': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/reporter-dev-server': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/resolver-default': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/runtime-browser-hmr': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/runtime-js': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/runtime-react-refresh': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/runtime-service-worker': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-babel': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-css': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-html': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-image': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-js': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11)) + '@parcel/transformer-json': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-postcss': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-posthtml': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-raw': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-react-refresh-wrap': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-svg': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + transitivePeerDependencies: + - '@swc/helpers' + - cssnano + - postcss + - purgecss + - relateurl + - srcset + - terser + - typescript + - uncss + + '@parcel/config-default@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.4.2)': + dependencies: + '@parcel/bundler-default': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/compressor-raw': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/core': 2.12.0(@swc/helpers@0.5.11) + '@parcel/namer-default': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/optimizer-css': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/optimizer-htmlnano': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(postcss@8.4.39)(relateurl@0.2.7)(terser@5.31.1)(typescript@5.4.2) + '@parcel/optimizer-image': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/optimizer-svgo': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/optimizer-swc': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-css': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-html': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-js': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-raw': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-svg': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/packager-wasm': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/reporter-dev-server': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/resolver-default': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/runtime-browser-hmr': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/runtime-js': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/runtime-react-refresh': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/runtime-service-worker': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-babel': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-css': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-html': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-image': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-js': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11)) + '@parcel/transformer-json': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-postcss': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-posthtml': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-raw': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-react-refresh-wrap': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/transformer-svg': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + transitivePeerDependencies: + - '@swc/helpers' + - cssnano + - postcss + - purgecss + - relateurl + - srcset + - terser + - typescript + - uncss + '@parcel/core@2.12.0(@swc/helpers@0.5.11)': dependencies: '@mischnic/json-sourcemap': 0.1.1 @@ -17863,6 +17964,16 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@parcel/fs@2.12.0(@swc/helpers@0.5.11)': + dependencies: + '@parcel/rust': 2.12.0 + '@parcel/types': 2.12.0(@swc/helpers@0.5.11) + '@parcel/utils': 2.12.0 + '@parcel/watcher': 2.4.1 + '@parcel/workers': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11)) + transitivePeerDependencies: + - '@swc/helpers' + '@parcel/graph@3.2.0': dependencies: nullthrows: 1.1.1 @@ -17885,6 +17996,18 @@ snapshots: - '@parcel/core' - '@swc/helpers' + '@parcel/node-resolver-core@3.3.0': + dependencies: + '@mischnic/json-sourcemap': 0.1.1 + '@parcel/diagnostic': 2.12.0 + '@parcel/fs': 2.12.0(@swc/helpers@0.5.11) + '@parcel/rust': 2.12.0 + '@parcel/utils': 2.12.0 + nullthrows: 1.1.1 + semver: 7.6.2 + transitivePeerDependencies: + - '@parcel/core' + '@parcel/node-resolver-core@3.3.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))': dependencies: '@mischnic/json-sourcemap': 0.1.1 @@ -18027,6 +18150,20 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@parcel/package-manager@2.12.0(@swc/helpers@0.5.11)': + dependencies: + '@parcel/diagnostic': 2.12.0 + '@parcel/fs': 2.12.0(@swc/helpers@0.5.11) + '@parcel/logger': 2.12.0 + '@parcel/node-resolver-core': 3.3.0 + '@parcel/types': 2.12.0(@swc/helpers@0.5.11) + '@parcel/utils': 2.12.0 + '@parcel/workers': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11)) + '@swc/core': 1.6.7(@swc/helpers@0.5.11) + semver: 7.6.2 + transitivePeerDependencies: + - '@swc/helpers' + '@parcel/packager-css@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)': dependencies: '@parcel/diagnostic': 2.12.0 @@ -18088,6 +18225,13 @@ snapshots: - '@parcel/core' - '@swc/helpers' + '@parcel/packager-ts@2.12.0(@swc/helpers@0.5.11)': + dependencies: + '@parcel/plugin': 2.12.0(@swc/helpers@0.5.11) + transitivePeerDependencies: + - '@parcel/core' + - '@swc/helpers' + '@parcel/packager-wasm@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)': dependencies: '@parcel/plugin': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) @@ -18102,6 +18246,13 @@ snapshots: - '@parcel/core' - '@swc/helpers' + '@parcel/plugin@2.12.0(@swc/helpers@0.5.11)': + dependencies: + '@parcel/types': 2.12.0(@swc/helpers@0.5.11) + transitivePeerDependencies: + - '@parcel/core' + - '@swc/helpers' + '@parcel/profiler@2.12.0': dependencies: '@parcel/diagnostic': 2.12.0 @@ -18359,6 +18510,32 @@ snapshots: - '@parcel/core' - '@swc/helpers' + '@parcel/transformer-typescript-types@2.12.0(@swc/helpers@0.5.11)(typescript@5.2.2)': + dependencies: + '@parcel/diagnostic': 2.12.0 + '@parcel/plugin': 2.12.0(@swc/helpers@0.5.11) + '@parcel/source-map': 2.1.1 + '@parcel/ts-utils': 2.12.0(typescript@5.2.2) + '@parcel/utils': 2.12.0 + nullthrows: 1.1.1 + typescript: 5.2.2 + transitivePeerDependencies: + - '@parcel/core' + - '@swc/helpers' + + '@parcel/transformer-typescript-types@2.12.0(@swc/helpers@0.5.11)(typescript@5.4.2)': + dependencies: + '@parcel/diagnostic': 2.12.0 + '@parcel/plugin': 2.12.0(@swc/helpers@0.5.11) + '@parcel/source-map': 2.1.1 + '@parcel/ts-utils': 2.12.0(typescript@5.4.2) + '@parcel/utils': 2.12.0 + nullthrows: 1.1.1 + typescript: 5.4.2 + transitivePeerDependencies: + - '@parcel/core' + - '@swc/helpers' + '@parcel/ts-utils@2.12.0(typescript@5.2.2)': dependencies: nullthrows: 1.1.1 @@ -18387,6 +18564,19 @@ snapshots: - '@parcel/core' - '@swc/helpers' + '@parcel/types@2.12.0(@swc/helpers@0.5.11)': + dependencies: + '@parcel/cache': 2.12.0(@swc/helpers@0.5.11) + '@parcel/diagnostic': 2.12.0 + '@parcel/fs': 2.12.0(@swc/helpers@0.5.11) + '@parcel/package-manager': 2.12.0(@swc/helpers@0.5.11) + '@parcel/source-map': 2.1.1 + '@parcel/workers': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11)) + utility-types: 3.11.0 + transitivePeerDependencies: + - '@parcel/core' + - '@swc/helpers' + '@parcel/utils@2.12.0': dependencies: '@parcel/codeframe': 2.12.0 @@ -18460,7 +18650,7 @@ snapshots: '@parcel/diagnostic': 2.12.0 '@parcel/logger': 2.12.0 '@parcel/profiler': 2.12.0 - '@parcel/types': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/types': 2.12.0(@swc/helpers@0.5.11) '@parcel/utils': 2.12.0 nullthrows: 1.1.1 @@ -18665,7 +18855,7 @@ snapshots: - supports-color - utf-8-validate - '@pmmmwh/react-refresh-webpack-plugin@0.4.3(react-refresh@0.9.0)(sockjs-client@1.4.0)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack-hot-middleware@2.26.1)(webpack@5.90.1(esbuild@0.20.2))': + '@pmmmwh/react-refresh-webpack-plugin@0.4.3(react-refresh@0.9.0)(sockjs-client@1.4.0)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack-hot-middleware@2.26.1)(webpack@5.90.1)': dependencies: ansi-html: 0.0.7 error-stack-parser: 2.1.4 @@ -18674,10 +18864,10 @@ snapshots: react-refresh: 0.9.0 schema-utils: 2.7.1 source-map: 0.7.4 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 optionalDependencies: sockjs-client: 1.4.0 - webpack-dev-server: 4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)) + webpack-dev-server: 4.11.1(debug@4.3.2)(webpack@5.90.1) webpack-hot-middleware: 2.26.1 '@pnpm/config.env-replace@1.1.0': {} @@ -20059,10 +20249,10 @@ snapshots: dependencies: memoizerific: 1.11.3 - '@storybook/addon-webpack5-compiler-babel@3.0.3(webpack@5.90.1(esbuild@0.20.2))': + '@storybook/addon-webpack5-compiler-babel@3.0.3(webpack@5.90.1)': dependencies: '@babel/core': 7.24.7 - babel-loader: 9.1.3(@babel/core@7.24.7)(webpack@5.90.1(esbuild@0.20.2)) + babel-loader: 9.1.3(@babel/core@7.24.7)(webpack@5.90.1) transitivePeerDependencies: - supports-color - webpack @@ -20151,7 +20341,7 @@ snapshots: - prettier - supports-color - '@storybook/builder-webpack5@8.1.11(encoding@0.1.13)(esbuild@0.20.2)(prettier@3.2.5)(typescript@5.5.3)': + '@storybook/builder-webpack5@8.1.11(encoding@0.1.13)(prettier@3.2.5)(typescript@5.5.3)': dependencies: '@storybook/channels': 8.1.11 '@storybook/client-logger': 8.1.11 @@ -20167,24 +20357,24 @@ snapshots: case-sensitive-paths-webpack-plugin: 2.4.0 cjs-module-lexer: 1.3.1 constants-browserify: 1.0.0 - css-loader: 6.11.0(webpack@5.90.1(esbuild@0.20.2)) + css-loader: 6.11.0(webpack@5.90.1) es-module-lexer: 1.5.4 express: 4.19.2 - fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.5.3)(webpack@5.90.1(esbuild@0.20.2)) + fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.5.3)(webpack@5.90.1) fs-extra: 11.2.0 - html-webpack-plugin: 5.5.0(webpack@5.90.1(esbuild@0.20.2)) + html-webpack-plugin: 5.5.0(webpack@5.90.1) magic-string: 0.30.10 path-browserify: 1.0.1 process: 0.11.10 semver: 7.6.2 - style-loader: 3.3.1(webpack@5.90.1(esbuild@0.20.2)) - terser-webpack-plugin: 5.3.6(esbuild@0.20.2)(webpack@5.90.1(esbuild@0.20.2)) + style-loader: 3.3.1(webpack@5.90.1) + terser-webpack-plugin: 5.3.6(webpack@5.90.1) ts-dedent: 2.2.0 url: 0.11.3 util: 0.12.5 util-deprecate: 1.0.2 - webpack: 5.90.1(esbuild@0.20.2) - webpack-dev-middleware: 6.1.3(webpack@5.90.1(esbuild@0.20.2)) + webpack: 5.90.1 + webpack-dev-middleware: 6.1.3(webpack@5.90.1) webpack-hot-middleware: 2.26.1 webpack-virtual-modules: 0.5.0 optionalDependencies: @@ -20494,13 +20684,13 @@ snapshots: '@storybook/node-logger@8.1.11': {} - '@storybook/preset-react-webpack@8.1.11(encoding@0.1.13)(esbuild@0.20.2)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3)': + '@storybook/preset-react-webpack@8.1.11(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3)': dependencies: '@storybook/core-webpack': 8.1.11(encoding@0.1.13)(prettier@3.2.5) '@storybook/docs-tools': 8.1.11(encoding@0.1.13)(prettier@3.2.5) '@storybook/node-logger': 8.1.11 '@storybook/react': 8.1.11(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3) - '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.5.3)(webpack@5.90.1(esbuild@0.20.2)) + '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.5.3)(webpack@5.90.1) '@types/node': 18.19.39 '@types/semver': 7.5.8 find-up: 5.0.0 @@ -20512,7 +20702,7 @@ snapshots: resolve: 1.22.8 semver: 7.6.2 tsconfig-paths: 4.2.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 optionalDependencies: typescript: 5.5.3 transitivePeerDependencies: @@ -20543,7 +20733,7 @@ snapshots: '@storybook/preview@8.1.11': {} - '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.5.3)(webpack@5.90.1(esbuild@0.20.2))': + '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.5.3)(webpack@5.90.1)': dependencies: debug: 4.3.4(supports-color@8.1.1) endent: 2.1.0 @@ -20553,7 +20743,7 @@ snapshots: react-docgen-typescript: 2.2.2(typescript@5.5.3) tslib: 2.6.3 typescript: 5.5.3 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 transitivePeerDependencies: - supports-color @@ -20587,10 +20777,10 @@ snapshots: - typescript - vite-plugin-glimmerx - '@storybook/react-webpack5@8.1.11(encoding@0.1.13)(esbuild@0.20.2)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3)': + '@storybook/react-webpack5@8.1.11(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3)': dependencies: - '@storybook/builder-webpack5': 8.1.11(encoding@0.1.13)(esbuild@0.20.2)(prettier@3.2.5)(typescript@5.5.3) - '@storybook/preset-react-webpack': 8.1.11(encoding@0.1.13)(esbuild@0.20.2)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3) + '@storybook/builder-webpack5': 8.1.11(encoding@0.1.13)(prettier@3.2.5)(typescript@5.5.3) + '@storybook/preset-react-webpack': 8.1.11(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3) '@storybook/react': 8.1.11(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.3) '@storybook/types': 8.1.11 '@types/node': 18.19.39 @@ -21293,7 +21483,7 @@ snapshots: '@types/vinyl@2.0.12': dependencies: '@types/expect': 1.20.4 - '@types/node': 15.14.9 + '@types/node': 20.14.9 '@types/ws@8.5.10': dependencies: @@ -22252,28 +22442,28 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@8.3.0(@babel/core@7.24.7)(webpack@5.90.1(esbuild@0.20.2)): + babel-loader@8.3.0(@babel/core@7.24.7)(webpack@5.90.1): dependencies: '@babel/core': 7.24.7 find-cache-dir: 3.3.2 loader-utils: 2.0.4 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - babel-loader@9.1.0(@babel/core@7.24.7)(webpack@5.90.1(esbuild@0.20.2)): + babel-loader@9.1.0(@babel/core@7.24.7)(webpack@5.90.1): dependencies: '@babel/core': 7.24.7 find-cache-dir: 3.3.2 schema-utils: 4.2.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - babel-loader@9.1.3(@babel/core@7.24.7)(webpack@5.90.1(esbuild@0.20.2)): + babel-loader@9.1.3(@babel/core@7.24.7)(webpack@5.90.1): dependencies: '@babel/core': 7.24.7 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 babel-messages@6.23.0: dependencies: @@ -23003,9 +23193,9 @@ snapshots: ci-info@3.9.0: {} - circular-dependency-plugin@5.2.2(webpack@5.90.1(esbuild@0.20.2)): + circular-dependency-plugin@5.2.2(webpack@5.90.1): dependencies: - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 citty@0.1.6: dependencies: @@ -23321,7 +23511,7 @@ snapshots: copy-descriptor@0.1.1: {} - copy-webpack-plugin@6.4.1(webpack@5.90.1(esbuild@0.20.2)): + copy-webpack-plugin@6.4.1(webpack@5.90.1): dependencies: cacache: 15.3.0 fast-glob: 3.3.2 @@ -23333,7 +23523,7 @@ snapshots: p-limit: 3.1.0 schema-utils: 3.3.0 serialize-javascript: 5.0.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 webpack-sources: 1.4.3 transitivePeerDependencies: - bluebird @@ -23468,7 +23658,7 @@ snapshots: css-functions-list@3.2.2: {} - css-loader@5.2.7(webpack@5.90.1(esbuild@0.20.2)): + css-loader@5.2.7(webpack@5.90.1): dependencies: icss-utils: 5.1.0(postcss@8.4.31) loader-utils: 2.0.4 @@ -23480,9 +23670,9 @@ snapshots: postcss-value-parser: 4.2.0 schema-utils: 3.3.0 semver: 7.6.2 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - css-loader@6.11.0(webpack@5.90.1(esbuild@0.20.2)): + css-loader@6.11.0(webpack@5.90.1): dependencies: icss-utils: 5.1.0(postcss@8.4.39) postcss: 8.4.39 @@ -23493,9 +23683,9 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.6.2 optionalDependencies: - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - css-minimizer-webpack-plugin@1.3.0(webpack@5.90.1(esbuild@0.20.2)): + css-minimizer-webpack-plugin@1.3.0(webpack@5.90.1): dependencies: cacache: 15.3.0 cssnano: 4.1.11 @@ -23505,7 +23695,7 @@ snapshots: schema-utils: 3.3.0 serialize-javascript: 5.0.1 source-map: 0.6.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 webpack-sources: 1.4.3 transitivePeerDependencies: - bluebird @@ -25054,11 +25244,11 @@ snapshots: dependencies: flat-cache: 5.0.0 - file-loader@4.3.0(webpack@5.90.1(esbuild@0.20.2)): + file-loader@4.3.0(webpack@5.90.1): dependencies: loader-utils: 1.4.2 schema-utils: 2.7.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 file-saver@2.0.5: {} @@ -25194,7 +25384,7 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@4.1.6(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1(esbuild@0.20.2)): + fork-ts-checker-webpack-plugin@4.1.6(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1): dependencies: '@babel/code-frame': 7.10.4 chalk: 2.4.2 @@ -25203,7 +25393,7 @@ snapshots: semver: 5.7.2 tapable: 1.1.3 typescript: 5.5.3 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 worker-rpc: 0.1.1 optionalDependencies: eslint: 8.57.0 @@ -25211,7 +25401,7 @@ snapshots: transitivePeerDependencies: - supports-color - fork-ts-checker-webpack-plugin@8.0.0(typescript@5.5.3)(webpack@5.90.1(esbuild@0.20.2)): + fork-ts-checker-webpack-plugin@8.0.0(typescript@5.5.3)(webpack@5.90.1): dependencies: '@babel/code-frame': 7.24.7 chalk: 4.1.2 @@ -25226,7 +25416,7 @@ snapshots: semver: 7.6.2 tapable: 2.2.1 typescript: 5.5.3 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 form-data-encoder@2.1.4: {} @@ -25862,14 +26052,14 @@ snapshots: html-tags@3.3.1: {} - html-webpack-plugin@5.5.0(webpack@5.90.1(esbuild@0.20.2)): + html-webpack-plugin@5.5.0(webpack@5.90.1): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 lodash: 4.17.21 pretty-error: 4.0.0 tapable: 2.2.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 htmlnano@2.1.1(postcss@8.4.39)(relateurl@0.2.7)(svgo@2.8.0)(terser@5.31.1)(typescript@5.2.2): dependencies: @@ -27782,11 +27972,11 @@ snapshots: left-pad@1.3.0: {} - less-loader@11.1.0(less@3.11.1)(webpack@5.90.1(esbuild@0.20.2)): + less-loader@11.1.0(less@3.11.1)(webpack@5.90.1): dependencies: klona: 2.0.6 less: 3.11.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 less@3.11.1: dependencies: @@ -28002,10 +28192,10 @@ snapshots: dependencies: lodash: 4.17.21 - lodash-webpack-plugin@0.11.6(webpack@5.90.1(esbuild@0.20.2)): + lodash-webpack-plugin@0.11.6(webpack@5.90.1): dependencies: lodash: 4.17.21 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 lodash.capitalize@4.2.1: {} @@ -28623,10 +28813,10 @@ snapshots: react: 18.2.0 tiny-warning: 1.0.3 - mini-css-extract-plugin@2.7.2(webpack@5.90.1(esbuild@0.20.2)): + mini-css-extract-plugin@2.7.2(webpack@5.90.1): dependencies: schema-utils: 4.2.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 minimalistic-assert@1.0.1: {} @@ -28745,11 +28935,11 @@ snapshots: pkg-types: 1.1.3 ufo: 1.5.3 - moment-locales-webpack-plugin@1.2.0(moment@2.29.4)(webpack@5.90.1(esbuild@0.20.2)): + moment-locales-webpack-plugin@1.2.0(moment@2.29.4)(webpack@5.90.1): dependencies: lodash.difference: 4.5.0 moment: 2.29.4 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 moment@2.29.4: {} @@ -29136,11 +29326,11 @@ snapshots: dependencies: boolbase: 1.0.0 - null-loader@4.0.1(webpack@5.90.1(esbuild@0.20.2)): + null-loader@4.0.1(webpack@5.90.1): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 nullthrows@1.1.1: {} @@ -29605,6 +29795,60 @@ snapshots: - typescript - uncss + parcel@2.12.0(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.2.2): + dependencies: + '@parcel/config-default': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.2.2) + '@parcel/core': 2.12.0(@swc/helpers@0.5.11) + '@parcel/diagnostic': 2.12.0 + '@parcel/events': 2.12.0 + '@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/logger': 2.12.0 + '@parcel/package-manager': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/reporter-cli': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/reporter-dev-server': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/reporter-tracer': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/utils': 2.12.0 + chalk: 4.1.2 + commander: 7.2.0 + get-port: 4.2.0 + transitivePeerDependencies: + - '@swc/helpers' + - cssnano + - postcss + - purgecss + - relateurl + - srcset + - terser + - typescript + - uncss + + parcel@2.12.0(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.4.2): + dependencies: + '@parcel/config-default': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(terser@5.31.1)(typescript@5.4.2) + '@parcel/core': 2.12.0(@swc/helpers@0.5.11) + '@parcel/diagnostic': 2.12.0 + '@parcel/events': 2.12.0 + '@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/logger': 2.12.0 + '@parcel/package-manager': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/reporter-cli': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/reporter-dev-server': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/reporter-tracer': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.11))(@swc/helpers@0.5.11) + '@parcel/utils': 2.12.0 + chalk: 4.1.2 + commander: 7.2.0 + get-port: 4.2.0 + transitivePeerDependencies: + - '@swc/helpers' + - cssnano + - postcss + - purgecss + - relateurl + - srcset + - terser + - typescript + - uncss + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -29824,7 +30068,7 @@ snapshots: optionalDependencies: postcss: 8.4.39 - postcss-loader@4.3.0(postcss@8.4.31)(webpack@5.90.1(esbuild@0.20.2)): + postcss-loader@4.3.0(postcss@8.4.31)(webpack@5.90.1): dependencies: cosmiconfig: 7.1.0 klona: 2.0.6 @@ -29832,15 +30076,15 @@ snapshots: postcss: 8.4.31 schema-utils: 3.3.0 semver: 7.6.2 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - postcss-loader@7.0.2(postcss@8.4.31)(webpack@5.90.1(esbuild@0.20.2)): + postcss-loader@7.0.2(postcss@8.4.31)(webpack@5.90.1): dependencies: cosmiconfig: 7.1.0 klona: 2.0.6 postcss: 8.4.31 semver: 7.6.2 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 postcss-merge-longhand@4.0.11: dependencies: @@ -30358,41 +30602,41 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)): + razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1): dependencies: '@babel/code-frame': 7.24.7 chalk: 4.1.2 filesize: 6.4.0 gzip-size: 6.0.0 jest-message-util: 26.6.2 - react-dev-utils: 11.0.4(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1(esbuild@0.20.2)) + react-dev-utils: 11.0.4(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1) react-error-overlay: 6.0.9 recursive-readdir: 2.2.3 resolve: 1.22.8 sockjs-client: 1.4.0 strip-ansi: 6.0.1 - webpack: 5.90.1(esbuild@0.20.2) - webpack-dev-server: 4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)) + webpack: 5.90.1 + webpack-dev-server: 4.11.1(debug@4.3.2)(webpack@5.90.1) transitivePeerDependencies: - eslint - supports-color - typescript - vue-template-compiler - razzle-plugin-scss@4.2.18(mini-css-extract-plugin@2.7.2(webpack@5.90.1(esbuild@0.20.2)))(postcss@8.4.31)(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)))(razzle@4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1(esbuild@0.20.2)))(mini-css-extract-plugin@2.7.2(webpack@5.90.1(esbuild@0.20.2)))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack-hot-middleware@2.26.1)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)): + razzle-plugin-scss@4.2.18(mini-css-extract-plugin@2.7.2(webpack@5.90.1))(postcss@8.4.31)(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1))(razzle@4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1))(mini-css-extract-plugin@2.7.2(webpack@5.90.1))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack-hot-middleware@2.26.1)(webpack@5.90.1))(webpack@5.90.1): dependencies: autoprefixer: 10.4.8(postcss@8.4.31) - css-loader: 5.2.7(webpack@5.90.1(esbuild@0.20.2)) + css-loader: 5.2.7(webpack@5.90.1) deepmerge: 4.3.1 - mini-css-extract-plugin: 2.7.2(webpack@5.90.1(esbuild@0.20.2)) + mini-css-extract-plugin: 2.7.2(webpack@5.90.1) postcss-load-config: 3.1.4(postcss@8.4.31) - postcss-loader: 4.3.0(postcss@8.4.31)(webpack@5.90.1(esbuild@0.20.2)) + postcss-loader: 4.3.0(postcss@8.4.31)(webpack@5.90.1) postcss-scss: 3.0.5 - razzle: 4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1(esbuild@0.20.2)))(mini-css-extract-plugin@2.7.2(webpack@5.90.1(esbuild@0.20.2)))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack-hot-middleware@2.26.1)(webpack@5.90.1(esbuild@0.20.2)) - razzle-dev-utils: 4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)) + razzle: 4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1))(mini-css-extract-plugin@2.7.2(webpack@5.90.1))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack-hot-middleware@2.26.1)(webpack@5.90.1) + razzle-dev-utils: 4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1) resolve-url-loader: 3.1.5 sass: 1.77.6 - sass-loader: 10.5.2(sass@1.77.6)(webpack@5.90.1(esbuild@0.20.2)) + sass-loader: 10.5.2(sass@1.77.6)(webpack@5.90.1) transitivePeerDependencies: - fibers - node-sass @@ -30400,58 +30644,58 @@ snapshots: - ts-node - webpack - razzle-start-server-webpack-plugin@4.2.18(webpack@5.90.1(esbuild@0.20.2)): + razzle-start-server-webpack-plugin@4.2.18(webpack@5.90.1): dependencies: - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - razzle@4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1(esbuild@0.20.2)))(mini-css-extract-plugin@2.7.2(webpack@5.90.1(esbuild@0.20.2)))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack-hot-middleware@2.26.1)(webpack@5.90.1(esbuild@0.20.2)): + razzle@4.2.18(@babel/core@7.24.7)(babel-preset-razzle@4.2.18)(eslint@8.57.0)(html-webpack-plugin@5.5.0(webpack@5.90.1))(mini-css-extract-plugin@2.7.2(webpack@5.90.1))(razzle-dev-utils@4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1))(sockjs-client@1.4.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack-hot-middleware@2.26.1)(webpack@5.90.1): dependencies: '@babel/plugin-transform-modules-commonjs': 7.24.7(@babel/core@7.24.7) - '@pmmmwh/react-refresh-webpack-plugin': 0.4.3(react-refresh@0.9.0)(sockjs-client@1.4.0)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack-hot-middleware@2.26.1)(webpack@5.90.1(esbuild@0.20.2)) + '@pmmmwh/react-refresh-webpack-plugin': 0.4.3(react-refresh@0.9.0)(sockjs-client@1.4.0)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack-hot-middleware@2.26.1)(webpack@5.90.1) autoprefixer: 10.4.8(postcss@8.4.31) babel-jest: 26.6.3(@babel/core@7.24.7) - babel-loader: 8.3.0(@babel/core@7.24.7)(webpack@5.90.1(esbuild@0.20.2)) + babel-loader: 8.3.0(@babel/core@7.24.7)(webpack@5.90.1) babel-plugin-transform-define: 2.1.4 babel-preset-razzle: 4.2.18 buffer: 6.0.3 chalk: 4.1.2 clean-css: 5.3.3 - copy-webpack-plugin: 6.4.1(webpack@5.90.1(esbuild@0.20.2)) - css-loader: 5.2.7(webpack@5.90.1(esbuild@0.20.2)) - css-minimizer-webpack-plugin: 1.3.0(webpack@5.90.1(esbuild@0.20.2)) + copy-webpack-plugin: 6.4.1(webpack@5.90.1) + css-loader: 5.2.7(webpack@5.90.1) + css-minimizer-webpack-plugin: 1.3.0(webpack@5.90.1) deepmerge: 4.3.1 dotenv: 8.6.0 dotenv-expand: 5.1.0 - file-loader: 4.3.0(webpack@5.90.1(esbuild@0.20.2)) + file-loader: 4.3.0(webpack@5.90.1) fs-extra: 9.1.0 - html-webpack-plugin: 5.5.0(webpack@5.90.1(esbuild@0.20.2)) + html-webpack-plugin: 5.5.0(webpack@5.90.1) inquirer: 7.3.3 jest: 26.6.3 - mini-css-extract-plugin: 2.7.2(webpack@5.90.1(esbuild@0.20.2)) + mini-css-extract-plugin: 2.7.2(webpack@5.90.1) mri: 1.2.0 - null-loader: 4.0.1(webpack@5.90.1(esbuild@0.20.2)) + null-loader: 4.0.1(webpack@5.90.1) pnp-webpack-plugin: 1.7.0(typescript@5.5.3) postcss: 8.4.31 postcss-load-config: 3.1.4(postcss@8.4.31) - postcss-loader: 4.3.0(postcss@8.4.31)(webpack@5.90.1(esbuild@0.20.2)) + postcss-loader: 4.3.0(postcss@8.4.31)(webpack@5.90.1) process: 0.11.10 - razzle-dev-utils: 4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)) - razzle-start-server-webpack-plugin: 4.2.18(webpack@5.90.1(esbuild@0.20.2)) - react-dev-utils: 11.0.4(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1(esbuild@0.20.2)) + razzle-dev-utils: 4.2.18(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1))(webpack@5.90.1) + razzle-start-server-webpack-plugin: 4.2.18(webpack@5.90.1) + react-dev-utils: 11.0.4(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1) react-refresh: 0.9.0 resolve: 1.22.8 sade: 1.8.1 source-map-support: 0.5.21 string-hash: 1.1.3 - style-loader: 2.0.0(webpack@5.90.1(esbuild@0.20.2)) + style-loader: 2.0.0(webpack@5.90.1) terminate: 2.8.0 - terser-webpack-plugin: 2.3.8(webpack@5.90.1(esbuild@0.20.2)) + terser-webpack-plugin: 2.3.8(webpack@5.90.1) tiny-async-pool: 1.3.0 - url-loader: 2.3.0(file-loader@4.3.0(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)) - webpack: 5.90.1(esbuild@0.20.2) - webpack-dev-server: 4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)) - webpack-manifest-plugin: 3.2.0(webpack@5.90.1(esbuild@0.20.2)) - webpackbar: 5.0.2(webpack@5.90.1(esbuild@0.20.2)) + url-loader: 2.3.0(file-loader@4.3.0(webpack@5.90.1))(webpack@5.90.1) + webpack: 5.90.1 + webpack-dev-server: 4.11.1(debug@4.3.2)(webpack@5.90.1) + webpack-manifest-plugin: 3.2.0(webpack@5.90.1) + webpackbar: 5.0.2(webpack@5.90.1) transitivePeerDependencies: - '@babel/core' - '@types/webpack' @@ -30682,7 +30926,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-dev-utils@11.0.4(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1(esbuild@0.20.2)): + react-dev-utils@11.0.4(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1): dependencies: '@babel/code-frame': 7.10.4 address: 1.1.2 @@ -30693,7 +30937,7 @@ snapshots: escape-string-regexp: 2.0.0 filesize: 6.1.0 find-up: 4.1.0 - fork-ts-checker-webpack-plugin: 4.1.6(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1(esbuild@0.20.2)) + fork-ts-checker-webpack-plugin: 4.1.6(eslint@8.57.0)(typescript@5.5.3)(vue-template-compiler@2.7.16)(webpack@5.90.1) global-modules: 2.0.0 globby: 11.0.1 gzip-size: 5.1.1 @@ -30708,7 +30952,7 @@ snapshots: shell-quote: 1.7.2 strip-ansi: 6.0.0 text-table: 0.2.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 optionalDependencies: typescript: 5.5.3 transitivePeerDependencies: @@ -30733,7 +30977,7 @@ snapshots: recompose: 0.27.1(react@18.2.0) shallowequal: 1.1.0 - react-docgen-typescript-plugin@1.0.8(typescript@5.5.3)(webpack@5.90.1(esbuild@0.20.2)): + react-docgen-typescript-plugin@1.0.8(typescript@5.5.3)(webpack@5.90.1): dependencies: debug: 4.3.4(supports-color@8.1.1) find-cache-dir: 3.3.2 @@ -30742,7 +30986,7 @@ snapshots: react-docgen-typescript: 2.2.2(typescript@5.5.3) tslib: 2.6.3 typescript: 5.5.3 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 transitivePeerDependencies: - supports-color @@ -31879,14 +32123,14 @@ snapshots: transitivePeerDependencies: - supports-color - sass-loader@10.5.2(sass@1.77.6)(webpack@5.90.1(esbuild@0.20.2)): + sass-loader@10.5.2(sass@1.77.6)(webpack@5.90.1): dependencies: klona: 2.0.6 loader-utils: 2.0.4 neo-async: 2.6.2 schema-utils: 3.3.0 semver: 7.6.2 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 optionalDependencies: sass: 1.77.6 @@ -32632,15 +32876,15 @@ snapshots: dependencies: js-tokens: 9.0.0 - style-loader@2.0.0(webpack@5.90.1(esbuild@0.20.2)): + style-loader@2.0.0(webpack@5.90.1): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - style-loader@3.3.1(webpack@5.90.1(esbuild@0.20.2)): + style-loader@3.3.1(webpack@5.90.1): dependencies: - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 stylehacks@4.0.3: dependencies: @@ -32877,7 +33121,7 @@ snapshots: dependencies: ps-tree: 1.2.0 - terser-webpack-plugin@2.3.8(webpack@5.90.1(esbuild@0.20.2)): + terser-webpack-plugin@2.3.8(webpack@5.90.1): dependencies: cacache: 13.0.1 find-cache-dir: 3.3.2 @@ -32887,32 +33131,28 @@ snapshots: serialize-javascript: 4.0.0 source-map: 0.6.1 terser: 4.8.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 webpack-sources: 1.4.3 transitivePeerDependencies: - bluebird - terser-webpack-plugin@5.3.10(esbuild@0.20.2)(webpack@5.90.1(esbuild@0.20.2)): + terser-webpack-plugin@5.3.10(webpack@5.90.1): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.31.1 - webpack: 5.90.1(esbuild@0.20.2) - optionalDependencies: - esbuild: 0.20.2 + webpack: 5.90.1 - terser-webpack-plugin@5.3.6(esbuild@0.20.2)(webpack@5.90.1(esbuild@0.20.2)): + terser-webpack-plugin@5.3.6(webpack@5.90.1): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.31.1 - webpack: 5.90.1(esbuild@0.20.2) - optionalDependencies: - esbuild: 0.20.2 + webpack: 5.90.1 terser@4.8.1: dependencies: @@ -33091,14 +33331,14 @@ snapshots: typescript: 5.5.3 yargs-parser: 20.2.9 - ts-loader@9.4.4(typescript@5.5.3)(webpack@5.90.1(esbuild@0.20.2)): + ts-loader@9.4.4(typescript@5.5.3)(webpack@5.90.1): dependencies: chalk: 4.1.2 enhanced-resolve: 5.17.0 micromatch: 4.0.7 semver: 7.6.2 typescript: 5.5.3 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 ts-pnp@1.2.0(typescript@5.5.3): optionalDependencies: @@ -33442,14 +33682,14 @@ snapshots: url-join@5.0.0: {} - url-loader@2.3.0(file-loader@4.3.0(webpack@5.90.1(esbuild@0.20.2)))(webpack@5.90.1(esbuild@0.20.2)): + url-loader@2.3.0(file-loader@4.3.0(webpack@5.90.1))(webpack@5.90.1): dependencies: loader-utils: 1.4.2 mime: 2.6.0 schema-utils: 2.7.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 optionalDependencies: - file-loader: 4.3.0(webpack@5.90.1(esbuild@0.20.2)) + file-loader: 4.3.0(webpack@5.90.1) url-parse-lax@3.0.0: dependencies: @@ -33974,16 +34214,16 @@ snapshots: - bufferutil - utf-8-validate - webpack-dev-middleware@5.3.4(webpack@5.90.1(esbuild@0.20.2)): + webpack-dev-middleware@5.3.4(webpack@5.90.1): dependencies: colorette: 2.0.20 memfs: 3.5.3 mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.2.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - webpack-dev-middleware@6.1.3(webpack@5.90.1(esbuild@0.20.2)): + webpack-dev-middleware@6.1.3(webpack@5.90.1): dependencies: colorette: 2.0.20 memfs: 3.5.3 @@ -33991,9 +34231,9 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.2.0 optionalDependencies: - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 - webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1(esbuild@0.20.2)): + webpack-dev-server@4.11.1(debug@4.3.2)(webpack@5.90.1): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -34022,8 +34262,8 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack: 5.90.1(esbuild@0.20.2) - webpack-dev-middleware: 5.3.4(webpack@5.90.1(esbuild@0.20.2)) + webpack: 5.90.1 + webpack-dev-middleware: 5.3.4(webpack@5.90.1) ws: 8.18.0 transitivePeerDependencies: - bufferutil @@ -34037,10 +34277,10 @@ snapshots: html-entities: 2.5.2 strip-ansi: 6.0.1 - webpack-manifest-plugin@3.2.0(webpack@5.90.1(esbuild@0.20.2)): + webpack-manifest-plugin@3.2.0(webpack@5.90.1): dependencies: tapable: 2.2.1 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 webpack-sources: 2.3.1 webpack-node-externals@3.0.0: {} @@ -34061,7 +34301,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.90.1(esbuild@0.20.2): + webpack@5.90.1: dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.5 @@ -34084,7 +34324,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(esbuild@0.20.2)(webpack@5.90.1(esbuild@0.20.2)) + terser-webpack-plugin: 5.3.10(webpack@5.90.1) watchpack: 2.4.1 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -34092,13 +34332,13 @@ snapshots: - esbuild - uglify-js - webpackbar@5.0.2(webpack@5.90.1(esbuild@0.20.2)): + webpackbar@5.0.2(webpack@5.90.1): dependencies: chalk: 4.1.2 consola: 2.15.3 pretty-time: 1.1.0 std-env: 3.7.0 - webpack: 5.90.1(esbuild@0.20.2) + webpack: 5.90.1 websocket-driver@0.7.4: dependencies: diff --git a/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml similarity index 100% rename from pnpm-workspace.yaml rename to frontend/pnpm-workspace.yaml diff --git a/frontend/volto-form-block/frontend/.dockerignore b/frontend/volto-form-block/frontend/.dockerignore new file mode 100644 index 0000000..71a9d07 --- /dev/null +++ b/frontend/volto-form-block/frontend/.dockerignore @@ -0,0 +1,6 @@ +*.log +build +cache +cypress +Dockerfile +node_modules diff --git a/frontend/volto-form-block/frontend/Dockerfile b/frontend/volto-form-block/frontend/Dockerfile new file mode 100644 index 0000000..395896d --- /dev/null +++ b/frontend/volto-form-block/frontend/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 +ARG VOLTO_VERSION +# TODO: Replace with +# FROM plone/frontend-builder:${VOLTO_VERSION} +# when the main image is ready +FROM ghcr.io/kitconcept/frontend-builder:${VOLTO_VERSION} as builder + +COPY --chown=node packages/volto-form-block /app/packages/volto-form-block +COPY --chown=node volto.config.js /app/ +COPY --chown=node package.json /app/package.json.temp + +RUN --mount=type=cache,id=pnpm,target=/app/.pnpm-store,uid=1000 <