Compare commits

..

1 Commits

Author SHA1 Message Date
81619d62ab Release 5.3.5 2024-10-30 18:13:46 +03:00
803 changed files with 25206 additions and 41345 deletions

View File

@ -99,12 +99,12 @@ executors:
wpcli_php_max_wporg: wpcli_php_max_wporg:
<<: *default_job_config <<: *default_job_config
docker: docker:
- image: mailpoet/wordpress:8.1_20230307.1 # We need to use 8.1 to emulate the WP.org environment - image: mailpoet/wordpress:8.1_20230307.1
wpcli_php_latest: wpcli_php_latest:
<<: *default_job_config <<: *default_job_config
docker: docker:
- image: mailpoet/wordpress:8.2_20241126.1 - image: mailpoet/wordpress:8.1_20230307.1
wpcli_php_mysql_oldest: wpcli_php_mysql_oldest:
<<: *default_job_config <<: *default_job_config
@ -115,7 +115,7 @@ executors:
wpcli_php_mysql_latest: wpcli_php_mysql_latest:
<<: *default_job_config <<: *default_job_config
docker: docker:
- image: mailpoet/wordpress:8.2_20241126.1 - image: mailpoet/wordpress:8.1_20230307.1
- image: cimg/mysql:8.0 - image: cimg/mysql:8.0
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_520_ci command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_520_ci
@ -141,8 +141,6 @@ jobs:
key: composer-{{ checksum "tasks/code_sniffer/composer.json" }}-{{ checksum "tasks/code_sniffer/composer.lock" }} key: composer-{{ checksum "tasks/code_sniffer/composer.json" }}-{{ checksum "tasks/code_sniffer/composer.lock" }}
- restore_cache: - restore_cache:
key: composer-{{ checksum "composer.json" }}-{{ checksum "composer.lock" }} key: composer-{{ checksum "composer.json" }}-{{ checksum "composer.lock" }}
- restore_cache:
key: composer-{{ checksum "../tests_env/composer.json" }}-{{ checksum "../tests_env/composer.lock" }}
- restore_cache: - restore_cache:
key: composer-prefixed-{{ checksum "prefixer-checksum" }} key: composer-prefixed-{{ checksum "prefixer-checksum" }}
- restore_cache: - restore_cache:
@ -159,7 +157,7 @@ jobs:
./do install ./do install
./do compile:all --env production --skip-tests ./do compile:all --env production --skip-tests
./do doctrine:generate-cache ./do doctrine:generate-cache
../tests_env/vendor/bin/codecept build vendor/bin/codecept build
./do twig:generate-cache ./do twig:generate-cache
- run: - run:
name: 'Check Prettier formatting' name: 'Check Prettier formatting'
@ -180,10 +178,6 @@ jobs:
key: composer-{{ checksum "composer.json" }}-{{ checksum "composer.lock" }} key: composer-{{ checksum "composer.json" }}-{{ checksum "composer.lock" }}
paths: paths:
- vendor - vendor
- save_cache:
key: composer-{{ checksum "../tests_env/composer.json" }}-{{ checksum "../tests_env/composer.lock" }}
paths:
- ../tests_env/vendor
- save_cache: - save_cache:
key: composer-prefixed-{{ checksum "prefixer-checksum" }} key: composer-prefixed-{{ checksum "prefixer-checksum" }}
paths: paths:
@ -197,10 +191,10 @@ jobs:
- run: - run:
name: Download additional WP Plugins for tests name: Download additional WP Plugins for tests
command: | command: |
./do download:woo-commerce-zip 9.6.1 ./do download:woo-commerce-zip 9.3.3
./do download:woo-commerce-subscriptions-zip 7.1.0 ./do download:woo-commerce-subscriptions-zip 6.8.0
./do download:woo-commerce-memberships-zip 1.26.5 ./do download:woo-commerce-memberships-zip 1.26.5
./do download:automate-woo-zip 6.1.5 ./do download:automate-woo-zip 6.1.0
- run: - run:
name: Dump tests ENV variables for acceptance tests name: Dump tests ENV variables for acceptance tests
command: | command: |
@ -1082,15 +1076,15 @@ workflows:
- acceptance_tests: - acceptance_tests:
<<: *slack-fail-post-step <<: *slack-fail-post-step
name: acceptance_oldest name: acceptance_oldest
woo_core_version: 9.5.2 woo_core_version: 9.2.3
woo_subscriptions_version: 7.0.0 woo_subscriptions_version: 6.7.0
woo_memberships_version: 1.25.2 woo_memberships_version: 1.25.2
automate_woo_version: 6.0.33 automate_woo_version: 6.0.33
mysql_command: --max_allowed_packet=100M mysql_command: --max_allowed_packet=100M
mysql_image: mysql:5.5 mysql_image: mysql:5.5
codeception_image_version: 7.4-cli_20220605.0 codeception_image_version: 7.4-cli_20220605.0
wordpress_image_version: 6.1.1-php7.4 # We use image with PHP 7.4 and install required WordPress version via CLI wordpress_image_version: 6.1.1-php7.4 # We use image with PHP 7.4 and install required WordPress version via CLI
wordpress_version: 6.6.2 wordpress_version: 6.5.5
requires: requires:
- build - build
- performance_tests: - performance_tests:
@ -1123,13 +1117,13 @@ workflows:
- integration_tests: - integration_tests:
<<: *slack-fail-post-step <<: *slack-fail-post-step
name: integration_oldest name: integration_oldest
woo_core_version: 9.5.2 woo_core_version: 9.2.3
woo_subscriptions_version: 7.0.0 woo_subscriptions_version: 6.7.0
woo_memberships_version: 1.25.2 woo_memberships_version: 1.25.2
automate_woo_version: 6.0.33 automate_woo_version: 6.0.33
codeception_image_version: 7.4-cli_20220605.0 codeception_image_version: 7.4-cli_20220605.0
wordpress_image_version: 6.1.1-php7.4 # We use image with PHP 7.4 and install required WordPress version via CLI # We use image with PHP 7.4 and install required WordPress version via CLI wordpress_image_version: 6.1.1-php7.4 # We use image with PHP 7.4 and install required WordPress version via CLI # We use image with PHP 7.4 and install required WordPress version via CLI
wordpress_version: 6.6.2 wordpress_version: 6.5.5
mysql_command: --max_allowed_packet=100M mysql_command: --max_allowed_packet=100M
mysql_image: mysql:5.5 mysql_image: mysql:5.5
requires: requires:
@ -1186,25 +1180,25 @@ workflows:
- acceptance_tests: - acceptance_tests:
<<: *slack-fail-post-step <<: *slack-fail-post-step
name: acceptance_with_premium_oldest name: acceptance_with_premium_oldest
woo_core_version: 9.5.2 woo_core_version: 9.2.3
woo_subscriptions_version: 7.0.0 woo_subscriptions_version: 6.7.0
woo_memberships_version: 1.25.2 woo_memberships_version: 1.25.2
automate_woo_version: 6.0.33 automate_woo_version: 6.0.33
codeception_image_version: 7.4-cli_20220605.0 codeception_image_version: 7.4-cli_20220605.0
wordpress_image_version: 6.1.1-php7.4 # We use image with PHP 7.4 and install required WordPress version via CLI wordpress_image_version: 6.1.1-php7.4 # We use image with PHP 7.4 and install required WordPress version via CLI
wordpress_version: 6.6.2 wordpress_version: 6.5.5
requires: requires:
- build_premium - build_premium
- integration_tests: - integration_tests:
<<: *slack-fail-post-step <<: *slack-fail-post-step
name: integration_with_premium_oldest name: integration_with_premium_oldest
woo_core_version: 9.5.2 woo_core_version: 9.2.3
woo_subscriptions_version: 7.0.0 woo_subscriptions_version: 6.7.0
woo_memberships_version: 1.25.2 woo_memberships_version: 1.25.2
automate_woo_version: 6.0.33 automate_woo_version: 6.0.33
codeception_image_version: 7.4-cli_20220605.0 codeception_image_version: 7.4-cli_20220605.0
wordpress_image_version: 6.1.1-php7.4 # We use image with PHP 7.4 and install required WordPress version via CLI wordpress_image_version: 6.1.1-php7.4 # We use image with PHP 7.4 and install required WordPress version via CLI
wordpress_version: 6.6.2 wordpress_version: 6.5.5
mysql_command: --max_allowed_packet=100M mysql_command: --max_allowed_packet=100M
mysql_image: mysql:5.5 mysql_image: mysql:5.5
requires: requires:

View File

@ -10,12 +10,6 @@ indent_size = 2
ij_smart_tabs = false ij_smart_tabs = false
max_line_length = off max_line_length = off
[packages/php/email-editor/**]
indent_style = tab
[packages/js/email-editor/**.{js,jsx,ts,tsx,scss}]
indent_style = tab
[*.php] [*.php]
ij_php_align_key_value_pairs = false ij_php_align_key_value_pairs = false
ij_php_align_multiline_chained_methods = false ij_php_align_multiline_chained_methods = false
@ -62,4 +56,3 @@ ij_php_space_after_colon_in_return_type = true
ij_php_space_before_else_keyword = true ij_php_space_before_else_keyword = true
ij_php_for_statement_new_line_after_left_paren = true ij_php_for_statement_new_line_after_left_paren = true
ij_php_class_brace_style = end_of_line ij_php_class_brace_style = end_of_line
ij_php_comma_after_last_array_element = true

View File

@ -39,15 +39,3 @@ e66c76133ec3ef667e382203426d91a4b4aa5174
# Prettier autoformatting # Prettier autoformatting
ab27eaee2df740c0add4331a7f8c115a87ecfa2b ab27eaee2df740c0add4331a7f8c115a87ecfa2b
# Move email editor to JS packages folder
912282f57ccc839491ff951ec5cf7aa10c14f429
# Switch email editor js packages to WP coding style
b2fb96f8793b63db629d5237010d87332330c51e
# Email editor Prettier autoformatting
8c604453b1d82e3a2c731241e1c96ea8b32ec716
# Move email editor components out of the engine folder
1c3ea9cd0a5fc8848a64d840e2fa16a6c7d8c1fe

View File

@ -20,28 +20,30 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: mailpoet/vendor path: mailpoet/vendor
key: ${{ runner.os }}-composer-mailpoet-${{ matrix.php-version }}-${{ hashFiles('mailpoet/composer.lock') }}-${{ hashFiles('mailpoet/composer.json') }} key: ${{ runner.os }}-composer-mailpoet-${{ matrix.php-version }}-${{ hashFiles('mailpoet/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-mailpoet-${{ matrix.php-version }}-
${{ runner.os }}-composer-mailpoet-
- name: Cache Composer vendor-prefixed dependencies for MailPoet - name: Cache Composer vendor-prefixed dependencies for MailPoet
id: vendor-prefixed-cache id: vendor-prefixed-cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: mailpoet/vendor-prefixed path: mailpoet/vendor-prefixed
key: ${{ runner.os }}-vendor-prefixed-${{ matrix.php-version }}-${{ hashFiles('mailpoet/prefixer/composer.lock') }}-${{ hashFiles('mailpoet/prefixer/composer.json') }} key: ${{ runner.os }}-vendor-prefixed-${{ matrix.php-version }}-${{ hashFiles('mailpoet/prefixer/composer.lock') }}
restore-keys: |
- name: Cache Composer vendor for test environment ${{ runner.os }}-prefixer-mailpoet-${{ matrix.php-version }}-
id: composer-tests-env-cache ${{ runner.os }}-prefixer-mailpoet-
uses: actions/cache@v4
with:
path: tests_env/vendor
key: ${{ runner.os }}-composer-mailpoet-${{ matrix.php-version }}-${{ hashFiles('tests_env/composer.lock') }}-${{ hashFiles('tests_env/composer.json') }}
- name: Cache Composer dependencies for Email Editor - name: Cache Composer dependencies for Email Editor
id: composer-email-editor-cache id: composer-email-editor-cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: packages/php/email-editor/vendor path: packages/php/email-editor/vendor
key: ${{ runner.os }}-composer-email-editor-${{ matrix.php-version }}-${{ hashFiles('packages/php/email-editor/composer.lock') }}-${{ hashFiles('packages/php/email-editor/composer.json') }} key: ${{ runner.os }}-composer-email-editor-${{ matrix.php-version }}-${{ hashFiles('packages/php/email-editor/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-email-editor-${{ matrix.php-version }}-
${{ runner.os }}-composer-email-editor-
- name: Set up PHP - name: Set up PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@ -55,16 +57,9 @@ jobs:
touch .env touch .env
working-directory: mailpoet working-directory: mailpoet
# Install Test Environment dependencies only if the cache was not hit
- name: Install test environment dependencies
if: steps.composer-tests-env-cache.outputs.cache-hit != 'true'
run: ../mailpoet/tools/vendor/composer.phar install
working-directory: tests_env
# Install MailPoet dependencies only if the cache was not hit # Install MailPoet dependencies only if the cache was not hit
- name: Install mailpoet dependencies - name: Install mailpoet dependencies
if: | if: steps.composer-mailpoet-cache.outputs.cache-hit != 'true'
steps.composer-mailpoet-cache.outputs.cache-hit != 'true' || steps.vendor-prefixed-cache.outputs.cache-hit != 'true'
run: ./tools/vendor/composer.phar install run: ./tools/vendor/composer.phar install
working-directory: mailpoet working-directory: mailpoet
@ -74,139 +69,11 @@ jobs:
run: ../../../mailpoet/tools/vendor/composer.phar install run: ../../../mailpoet/tools/vendor/composer.phar install
working-directory: packages/php/email-editor working-directory: packages/php/email-editor
# Dump Email Editor autoload
# This is needed to refresh classmap autoload when the composer cache is hit
- name: Dump email-editor autoload
run: ../../../mailpoet/tools/vendor/composer.phar dump-autoload
working-directory: packages/php/email-editor
# Dump MailPoet autoload
# This is needed to refresh classmap autoload when the composer cache is hit
- name: Dump MailPoet autoload
run: ./tools/vendor/composer.phar dump-autoload
working-directory: mailpoet
# Run Email Editor unit tests # Run Email Editor unit tests
- name: Run email-editor package unit tests - name: Run email-editor package unit tests
run: ../../../tests_env/vendor/bin/codecept build && ../../../mailpoet/tools/vendor/composer.phar unit-test run: ./vendor/bin/codecept build && ../../../mailpoet/tools/vendor/composer.phar unit-test
working-directory: packages/php/email-editor working-directory: packages/php/email-editor
- name: Run email-editor package integration tests - name: Run email-editor package integration tests
run: ../../../mailpoet/tools/vendor/composer.phar integration-test run: ../../../mailpoet/tools/vendor/composer.phar integration-test
working-directory: packages/php/email-editor working-directory: packages/php/email-editor
code-style:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install tools
run: |
COMPOSER_DEV_MODE=1 php tools/install.php
touch .env
working-directory: mailpoet
- name: Install composer dependencies
run: ../../tools/vendor/composer.phar install
working-directory: mailpoet/tasks/code_sniffer
- name: Run code style check
run: ../../../mailpoet/tools/vendor/composer.phar code-style
working-directory: packages/php/email-editor
phpstan-static-analysis:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['7.4', '8.2']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Cache Composer vendor dependencies for MailPoet
id: composer-mailpoet-cache
uses: actions/cache@v4
with:
path: mailpoet/vendor
key: ${{ runner.os }}-composer-mailpoet-${{ matrix.php-version }}-${{ hashFiles('mailpoet/composer.lock') }}-${{ hashFiles('mailpoet/composer.json') }}
- name: Cache Composer vendor-prefixed dependencies for MailPoet
id: vendor-prefixed-cache
uses: actions/cache@v4
with:
path: mailpoet/vendor-prefixed
key: ${{ runner.os }}-vendor-prefixed-${{ matrix.php-version }}-${{ hashFiles('mailpoet/prefixer/composer.lock') }}-${{ hashFiles('mailpoet/prefixer/composer.json') }}
- name: Cache Composer vendor for test environment
id: composer-tests-env-cache
uses: actions/cache@v4
with:
path: tests_env/vendor
key: ${{ runner.os }}-composer-mailpoet-${{ matrix.php-version }}-${{ hashFiles('tests_env/composer.lock') }}-${{ hashFiles('tests_env/composer.json') }}
- name: Cache Composer dependencies for Email Editor
id: composer-email-editor-cache
uses: actions/cache@v4
with:
path: packages/php/email-editor/vendor
key: ${{ runner.os }}-composer-email-editor-${{ matrix.php-version }}-${{ hashFiles('packages/php/email-editor/composer.lock') }}-${{ hashFiles('packages/php/email-editor/composer.json') }}
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: gd
- name: Install tools
run: |
COMPOSER_DEV_MODE=1 php tools/install.php
touch .env
working-directory: mailpoet
# Install Test Environment dependencies only if the cache was not hit
- name: Install test environment dependencies
if: steps.composer-tests-env-cache.outputs.cache-hit != 'true'
run: ../mailpoet/tools/vendor/composer.phar install
working-directory: tests_env
# Install MailPoet dependencies only if the cache was not hit
- name: Install mailpoet dependencies
if: |
steps.composer-mailpoet-cache.outputs.cache-hit != 'true' || steps.vendor-prefixed-cache.outputs.cache-hit != 'true'
run: ./tools/vendor/composer.phar install
working-directory: mailpoet
# Install Email Editor dependencies only if the cache was not hit
- name: Install email-editor dependencies
if: steps.composer-email-editor-cache.outputs.cache-hit != 'true'
run: ../../../mailpoet/tools/vendor/composer.phar install
working-directory: packages/php/email-editor
- name: Install composer dependencies
run: ../../tools/vendor/composer.phar install
working-directory: mailpoet/tasks/phpstan
# Dump Email Editor autoload
# This is needed to refresh classmap autoload when the composer cache is hit
- name: Dump email-editor autoload
run: ../../../mailpoet/tools/vendor/composer.phar dump-autoload
working-directory: packages/php/email-editor
# Dump MailPoet autoload
# This is needed to refresh classmap autoload when the composer cache is hit
- name: Dump MailPoet autoload
run: ./tools/vendor/composer.phar dump-autoload
working-directory: mailpoet
- name: Run code phpstan
run: ../../../mailpoet/tools/vendor/composer.phar phpstan -- --php-version=${{ matrix.php-version == '7.4' && '70400' || '80200' }}
working-directory: packages/php/email-editor

1
.gitignore vendored
View File

@ -10,4 +10,3 @@ mailpoet-premium
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
/wordpress /wordpress
packages/php/*/vendor packages/php/*/vendor
tests_env/vendor

View File

@ -4,5 +4,3 @@
[ "$MP_GIT_HOOKS_ENABLE" != "true" ] && exit 0 [ "$MP_GIT_HOOKS_ENABLE" != "true" ] && exit 0
installIfUpdates installIfUpdates
./do cleanup:cached-files

View File

@ -4,5 +4,3 @@
npx lint-staged -c mailpoet/package.json --cwd mailpoet npx lint-staged -c mailpoet/package.json --cwd mailpoet
npx lint-staged -c package.json npx lint-staged -c package.json
npx lint-staged -c packages/js/email-editor/package.json --cwd packages/js/email-editor
npx lint-staged -c packages/php/email-editor/.lintstagedrc.json --cwd packages/php/email-editor

View File

@ -25,5 +25,3 @@ vendor-prefixed
/mailpoet/views /mailpoet/views
/mailpoet-premium /mailpoet-premium
/wordpress /wordpress
/packages/php/email-editor
/packages/js/email-editor

View File

@ -1,30 +0,0 @@
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
steps:
build:
image: node:current-bookworm-slim
commands:
- apt update
- apt install php php-symfony bash -y
- npm install pnpm
- cd mailpoet
- bash build.sh
- mkdir ../output
- mv mailpoet.zip ../output
- cd ..
release:
image: woodpeckerci/plugin-gitea-release:latest
settings:
base_url: https://git.cavemanon.xyz
api_key:
from_secret: releasesmithapikey
files: "output/"
prerelease: false
title: "${CI_COMMIT_TAG}"
when:
- event: tag

View File

@ -25,7 +25,7 @@ services:
container_name: mp-wp container_name: mp-wp
build: build:
context: . context: .
dockerfile: dev/php82/Dockerfile dockerfile: dev/php81/Dockerfile
args: args:
UID: ${UID:-1000} UID: ${UID:-1000}
GID: ${GID:-1000} GID: ${GID:-1000}
@ -54,7 +54,6 @@ services:
- './pnpm-lock.yaml:/var/www/html/wp-content/plugins/pnpm-lock.yaml' - './pnpm-lock.yaml:/var/www/html/wp-content/plugins/pnpm-lock.yaml'
- './pnpm-workspace.yaml:/var/www/html/wp-content/plugins/pnpm-workspace.yaml' - './pnpm-workspace.yaml:/var/www/html/wp-content/plugins/pnpm-workspace.yaml'
- './patches:/var/www/html/wp-content/plugins/patches' - './patches:/var/www/html/wp-content/plugins/patches'
- './tests_env:/var/www/html/wp-content/plugins/tests_env'
- './mailpoet:/var/www/html/wp-content/plugins/mailpoet' - './mailpoet:/var/www/html/wp-content/plugins/mailpoet'
- './mailpoet-premium:/var/www/html/wp-content/plugins/mailpoet-premium' - './mailpoet-premium:/var/www/html/wp-content/plugins/mailpoet-premium'
- './packages:/var/www/html/wp-content/plugins/packages' - './packages:/var/www/html/wp-content/plugins/packages'

2718
mailpoet/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,6 @@ class RoboFile extends \Robo\Tasks {
return $this->taskExecStack() return $this->taskExecStack()
->stopOnFail() ->stopOnFail()
->exec('./tools/vendor/composer.phar install') ->exec('./tools/vendor/composer.phar install')
->exec('cd ../packages/php/email-editor && ../../../mailpoet/tools/vendor/composer.phar install && cd -')
->exec('cd .. && pnpm install --frozen-lockfile --prefer-offline') ->exec('cd .. && pnpm install --frozen-lockfile --prefer-offline')
->addCode([$this, 'cleanupCachedFiles']) ->addCode([$this, 'cleanupCachedFiles'])
->run(); ->run();
@ -33,7 +32,6 @@ class RoboFile extends \Robo\Tasks {
return $this->taskExecStack() return $this->taskExecStack()
->stopOnFail() ->stopOnFail()
->exec('./tools/vendor/composer.phar install') ->exec('./tools/vendor/composer.phar install')
->exec('cd ../packages/php/email-editor && ../../../mailpoet/tools/vendor/composer.phar install && cd -')
->addCode([$this, 'cleanupCachedFiles']) ->addCode([$this, 'cleanupCachedFiles'])
->run(); ->run();
} }
@ -210,7 +208,7 @@ class RoboFile extends \Robo\Tasks {
$env = ($opts['env']) ? $env = ($opts['env']) ?
sprintf('./node_modules/.bin/cross-env NODE_ENV="%s"', $opts['env']) : sprintf('./node_modules/.bin/cross-env NODE_ENV="%s"', $opts['env']) :
null; null;
return $this->_exec($env . ' ./node_modules/webpack/bin/webpack.js --env BUILD_TESTS=' . ($opts['skip-tests'] ? 'skip' : 'build') . ' --env BUILD_ONLY_TESTS=' . ($opts['only-tests'] ? 'true' : 'false')); return $this->_exec($env . ' ./node_modules/webpack/bin/webpack.js --env BUILD_TESTS=' . ($opts['skip-tests'] ? 'skip' : 'build') . '--env BUILD_ONLY_TESTS=' . ($opts['only-tests'] ? 'true' : 'false'));
} }
public function compileCss($opts = ['env' => null]) { public function compileCss($opts = ['env' => null]) {
@ -332,7 +330,7 @@ class RoboFile extends \Robo\Tasks {
} }
public function testUnit(array $opts = ['file' => null, 'xml' => false, 'multisite' => false, 'debug' => false]) { public function testUnit(array $opts = ['file' => null, 'xml' => false, 'multisite' => false, 'debug' => false]) {
$command = '../tests_env/vendor/bin/codecept run unit'; $command = 'vendor/bin/codecept run unit';
if ($opts['file']) { if ($opts['file']) {
$command .= ' -f ' . $opts['file']; $command .= ' -f ' . $opts['file'];
@ -406,7 +404,7 @@ class RoboFile extends \Robo\Tasks {
return $this->testIntegration($opts); return $this->testIntegration($opts);
} }
public function testAcceptance($opts = ['file' => null, 'skip-deps' => false, 'group' => null, 'timeout' => null, 'disable-hpos' => false, 'enable-hpos-sync' => false, 'enable-hpos' => false, 'wordpress-version' => null, 'skip-plugins' => false]) { public function testAcceptance($opts = ['file' => null, 'skip-deps' => false, 'group' => null, 'timeout' => null, 'disable-hpos' => false, 'enable-hpos-sync' => false, 'enable-hpos' => false, 'wordpress-version' => null]) {
return $this->runTestsInContainer($opts); return $this->runTestsInContainer($opts);
} }
@ -422,7 +420,7 @@ class RoboFile extends \Robo\Tasks {
->option('env', 'US=' . $opts['us']) ->option('env', 'US=' . $opts['us'])
->option('env', 'PW=' . $opts['pw']) ->option('env', 'PW=' . $opts['pw'])
->option('env', 'K6_BROWSER_HEADLESS=' . ($opts['head'] ? 'false' : 'true')) ->option('env', 'K6_BROWSER_HEADLESS=' . ($opts['head'] ? 'false' : 'true'))
->option('env', 'K6_BROWSER_TIMEOUT=' . getenv('K6_BROWSER_TIMEOUT')) ->option('env', 'K6_BROWSER_TIMEOUT=120s')
->option('env', 'SCENARIO=' . $opts['scenario']) ->option('env', 'SCENARIO=' . $opts['scenario'])
->arg($path ?? "$dir/tests/performance/scenarios.js") ->arg($path ?? "$dir/tests/performance/scenarios.js")
->dir($dir)->run(); ->dir($dir)->run();
@ -439,7 +437,6 @@ class RoboFile extends \Robo\Tasks {
->option('env', 'SCENARIO=' . $opts['scenario']) ->option('env', 'SCENARIO=' . $opts['scenario'])
->option('env', 'K6_CLOUD_TOKEN=' . getenv('K6_CLOUD_TOKEN')) ->option('env', 'K6_CLOUD_TOKEN=' . getenv('K6_CLOUD_TOKEN'))
->option('env', 'K6_CLOUD_ID=' . getenv('K6_CLOUD_ID')) ->option('env', 'K6_CLOUD_ID=' . getenv('K6_CLOUD_ID'))
->option('env', 'K6_BROWSER_TIMEOUT=' . getenv('K6_BROWSER_TIMEOUT'))
->option('env', 'K6_PROJECT_NAME=' . $opts['scenario']) ->option('env', 'K6_PROJECT_NAME=' . $opts['scenario'])
->option('out', 'cloud') ->option('out', 'cloud')
->arg($path ?? "$dir/tests/performance/scenarios.js") ->arg($path ?? "$dir/tests/performance/scenarios.js")
@ -514,13 +511,13 @@ class RoboFile extends \Robo\Tasks {
} }
public function testFailedUnit() { public function testFailedUnit() {
$this->_exec('../tests_env/vendor/bin/codecept build'); $this->_exec('vendor/bin/codecept build');
return $this->_exec('../tests_env/vendor/bin/codecept run unit -g failed'); return $this->_exec('vendor/bin/codecept run unit -g failed');
} }
public function testFailedIntegration() { public function testFailedIntegration() {
$this->_exec('../tests_env/vendor/bin/codecept build'); $this->_exec('vendor/bin/codecept build');
return $this->_exec('../tests_env/vendor/bin/codecept run integration -g failed'); return $this->_exec('vendor/bin/codecept run integration -g failed');
} }
public function containerDump() { public function containerDump() {
@ -686,21 +683,11 @@ class RoboFile extends \Robo\Tasks {
} }
public function qaLintJavascript() { public function qaLintJavascript() {
$collection = $this->collectionBuilder(); return $this->_exec('pnpm run check-types && pnpm run lint');
return $collection->taskExecStack()
->stopOnFail()
->exec('pnpm run check-types && pnpm run lint')
->exec('cd .. && cd packages/js/email-editor && pnpm run check-types && pnpm run lint:js')
->run();
} }
public function qaLintCss() { public function qaLintCss() {
$collection = $this->collectionBuilder(); return $this->_exec('pnpm run stylelint-check -- "assets/css/src/**/*.scss"');
return $collection->taskExecStack()
->stopOnFail()
->exec('pnpm run stylelint-check -- "assets/css/src/**/*.scss"')
->exec('cd .. && cd packages/js/email-editor && pnpm run lint:css')
->run();
} }
public function qaCodeSniffer(array $filesToCheck, $opts = ['severity' => 'all']) { public function qaCodeSniffer(array $filesToCheck, $opts = ['severity' => 'all']) {
@ -761,7 +748,7 @@ class RoboFile extends \Robo\Tasks {
return $this->collectionBuilder() return $this->collectionBuilder()
->taskExec( ->taskExec(
'./tasks/code_sniffer/vendor/bin/phpcbf ' . './tasks/code_sniffer/vendor/bin/phpcbf ' .
'--standard=tasks/code_sniffer/MailPoet/free-ruleset.xml ' . '--standard=./tasks/code_sniffer/MailPoet ' .
'--runtime-set testVersion 7.4-8.2 ' . '--runtime-set testVersion 7.4-8.2 ' .
$filePath . ' -n' $filePath . ' -n'
) )
@ -787,7 +774,7 @@ class RoboFile extends \Robo\Tasks {
} }
// make sure Codeception support files are present to avoid invalid errors when running PHPStan // make sure Codeception support files are present to avoid invalid errors when running PHPStan
$this->_exec('../tests_env/vendor/bin/codecept build'); $this->_exec('vendor/bin/codecept build');
// PHPStan must be run out of main plugin directory to avoid its autoloading // PHPStan must be run out of main plugin directory to avoid its autoloading
// from vendor/autoload.php where some dev dependencies cause conflicts. // from vendor/autoload.php where some dev dependencies cause conflicts.

View File

@ -0,0 +1,61 @@
.mailpoet_feature_announcement {
float: right;
}
.button.mailpoet_feature_announcement_button {
height: 28px;
min-height: auto;
padding: 0 5px 1px;
position: relative;
@include respond-to(small-screen) {
margin-top: 10px;
}
}
.mailpoet_feature_announcement_icon {
line-height: 28px;
}
.mailpoet_feature_announcement_dot:before {
background: #d54e21;
border-radius: 10px;
content: '';
display: block;
height: 10px;
position: absolute;
right: -4px;
top: -4px;
width: 10px;
}
.mailpoet_in_beamer_update_notice {
background: #f00;
bottom: 0;
box-sizing: border-box;
color: #fff;
font-size: 20px;
margin: 0;
padding: 20px 10px;
position: fixed;
right: -400px;
text-align: center;
transition: right 0.2s ease-in;
width: 400px;
z-index: 10000000000; // really has to be this high
a {
color: #fff;
text-decoration: underline;
&:hover,
&:focus {
color: #fff;
text-decoration: none;
}
}
.beamer_show & {
right: 0;
}
}

View File

@ -95,6 +95,10 @@ h1.title.mailpoet-newsletter-listing-heading {
#mailpoet_editor_steps_heading { #mailpoet_editor_steps_heading {
.mailpoet-top-bar { .mailpoet-top-bar {
left: 0; left: 0;
.mailpoet-top-bar-beamer {
top: 4px;
}
} }
} }

View File

@ -161,12 +161,3 @@ ul.sending-method-benefits {
.mailpoet_install_premium_message { .mailpoet_install_premium_message {
margin-bottom: $grid-gap-medium; margin-bottom: $grid-gap-medium;
} }
.mailpoet-verify-key-button {
height: 36px;
}
.mailpoet-premium-key-toggle {
height: 34px;
padding: 0 10px !important;
}

View File

@ -10,15 +10,3 @@
} }
} }
} }
// WP registration form
form#registerform .g-recaptcha:not([data-size='invisible']) {
scale: 0.9;
-webkit-transform-origin: 0 0;
transform-origin: 0 0;
}
// WC registration form
form.woocommerce-form-register .g-recaptcha {
padding-inline-start: 3px;
}

View File

@ -1,3 +1,5 @@
$beamer-dot-size: 8px;
.mailpoet-top-bar { .mailpoet-top-bar {
align-items: center; align-items: center;
background-color: $color-white; background-color: $color-white;
@ -81,7 +83,7 @@
} }
} }
.mailpoet-top-bar-tutorial { .mailpoet-top-bar-beamer {
align-items: center; align-items: center;
background-color: $color-white; background-color: $color-white;
border: none; border: none;
@ -94,7 +96,6 @@
position: relative; position: relative;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
top: 4px;
width: 75px; width: 75px;
svg { svg {
@ -102,3 +103,15 @@
width: 100%; width: 100%;
} }
} }
.mailpoet-top-bar-beamer-dot:before {
background: $color-editor-warning;
border-radius: $beamer-dot-size;
content: '';
display: block;
height: $beamer-dot-size;
position: absolute;
right: 2px;
top: 2px;
width: $beamer-dot-size;
}

View File

@ -87,6 +87,7 @@
@import 'components-plugin/newsletter-types'; @import 'components-plugin/newsletter-types';
@import 'components-plugin/newsletter-template-styles'; @import 'components-plugin/newsletter-template-styles';
@import 'components-plugin/welcome-wizard'; @import 'components-plugin/welcome-wizard';
@import 'components-plugin/feature-announcement';
@import 'components-plugin/newsletter-congratulate'; @import 'components-plugin/newsletter-congratulate';
@import 'components-plugin/discounts'; @import 'components-plugin/discounts';
@import 'components-plugin/review-request'; @import 'components-plugin/review-request';

View File

@ -53,7 +53,7 @@ function exportMixpanel() {
if ( if (
window.mailpoet_analytics_enabled && window.mailpoet_analytics_enabled &&
window.mailpoet_3rd_party_libs_enabled window.MailPoet.libs3rdPartyEnabled
) { ) {
window.MailPoet.trackEvent = track; window.MailPoet.trackEvent = track;
} else { } else {
@ -104,30 +104,11 @@ function cacheEvent(forced, name, data, options, callback) {
} }
export function initializeMixpanelWhenLoaded() { export function initializeMixpanelWhenLoaded() {
const MAX_RETRY = 5; if (typeof window.mixpanel === 'object') {
let intervalId;
let retryCount = 0;
const setupMixpanel = () => {
exportMixpanel(); exportMixpanel();
trackCachedEvents(); trackCachedEvents();
};
if (typeof window.mixpanel === 'object') {
setupMixpanel();
} else { } else {
intervalId = setInterval(() => { setTimeout(initializeMixpanelWhenLoaded, 100);
if (typeof window.mixpanel === 'object') {
clearInterval(intervalId);
setupMixpanel();
} else {
retryCount += 1;
}
if (retryCount > MAX_RETRY) {
clearInterval(intervalId);
}
}, 100);
} }
} }

View File

@ -0,0 +1,33 @@
import classnames from 'classnames';
import { MailPoet } from 'mailpoet';
import { withFeatureAnnouncement } from './with-feature-announcement';
type Props = {
hasNews: boolean;
onBeamerClick: () => void;
};
function FeatureAnnouncementComponent({ hasNews, onBeamerClick }: Props) {
const buttonClasses = classnames(
'button mailpoet_feature_announcement_button',
hasNews ? 'mailpoet_feature_announcement_dot' : '',
);
return (
<div className="mailpoet_feature_announcement">
<button
type="button"
onClick={onBeamerClick}
className={buttonClasses}
title={MailPoet.I18n.t('whatsNew')}
>
<span className="mailpoet_feature_announcement_icon dashicons dashicons-carrot" />
</button>
<span id="beamer-empty-element" />
</div>
);
}
const FeatureAnnouncement = withFeatureAnnouncement(
FeatureAnnouncementComponent,
);
export { FeatureAnnouncement };

View File

@ -0,0 +1,111 @@
import { ComponentType, FC } from 'react';
import { MailPoet } from 'mailpoet';
import ReactStringReplace from 'react-string-replace';
import jQuery from 'jquery';
import { noop } from 'lodash';
interface FeatureAnnouncementWindow extends Window {
Beamer: {
show: () => void;
};
mailpoet_feature_announcement_has_news: boolean;
mailpoet_update_available: boolean;
beamer_config: {
product_id: string;
selector: string;
language: string;
callback: () => void;
filter?: string;
};
mailpoet_user_locale: string;
}
declare let window: FeatureAnnouncementWindow;
export const withFeatureAnnouncement = <P extends Record<string, unknown>>(
Component: ComponentType<P>,
): FC<Omit<P, 'hasNews' | 'onBeamerClick'>> => {
const isBeamerInitialized = () => typeof window.Beamer !== 'undefined';
let showDot = window.mailpoet_feature_announcement_has_news;
let beamerCallback;
function showPluginUpdateNotice() {
if (
!window.mailpoet_update_available ||
document.getElementById('mailpoet_update_notice')
) {
return;
}
const updateMailPoetNotice = ReactStringReplace(
MailPoet.I18n.t('updateMailPoetNotice'),
/\[link\](.*?)\[\/link\]/,
(match) => `<a href="update-core.php">${match}</a>`,
).join('');
jQuery('#beamerOverlay').append(
`<p id="mailpoet_update_notice" class="mailpoet_in_beamer_update_notice">${updateMailPoetNotice}</p>`,
);
}
function updateLastAnnouncementSeenValue() {
const data = { last_announcement_seen: Math.floor(Date.now() / 1000) };
void MailPoet.Ajax.post({
api_version: MailPoet.apiVersion,
endpoint: 'user_flags',
action: 'set',
data,
});
}
function loadBeamer() {
window.beamer_config = {
product_id: 'VvHbhYWy7118',
selector: '#beamer-empty-element',
language: window.mailpoet_user_locale,
callback: beamerCallback,
};
if (MailPoet.isWoocommerceActive) {
window.beamer_config.filter = 'woocommerce';
}
MailPoet.Modal.loading(true);
window.mailpoet_feature_announcement_has_news = false;
const s = document.createElement('script');
s.type = 'text/javascript';
s.src = 'https://app.getbeamer.com/js/beamer-embed.js';
document.getElementsByTagName('body')[0].appendChild(s);
}
function showBeamer(event = null) {
if (event) {
event.preventDefault();
}
if (!isBeamerInitialized()) {
loadBeamer();
return;
}
showDot = false;
beamerCallback = noop; // We show Beamer panel only on first callback after initialization
MailPoet.Modal.loading(false);
window.Beamer.show();
updateLastAnnouncementSeenValue();
showPluginUpdateNotice();
}
beamerCallback = () => {
if (!isBeamerInitialized()) {
return;
}
showBeamer();
};
return function withFeatureAnnouncementRenderer({
...props
}: Omit<P, 'hasNews' | 'onBeamerClick'>) {
return (
<Component
{...(props as P)}
onBeamerClick={(e) => showBeamer(e)}
hasNews={showDot}
/>
);
};
};

View File

@ -16,11 +16,7 @@ export type ApiError = {
export const initializeApi = () => { export const initializeApi = () => {
apiFetch.use((options, next) => { apiFetch.use((options, next) => {
if ( if (options.path && options.path.startsWith('/wc-analytics/')) {
options.path &&
(options.path.startsWith('/wc-analytics/') ||
options.path.startsWith('/wp/v2/'))
) {
return apiFetch.createRootURLMiddleware(`${api.root}/`)(options, next); return apiFetch.createRootURLMiddleware(`${api.root}/`)(options, next);
} }
return apiFetch.createRootURLMiddleware(apiUrl)(options, next); return apiFetch.createRootURLMiddleware(apiUrl)(options, next);

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { TopBarWithBoundary } from 'common/top-bar/top-bar'; import { TopBarWithBeamer } from 'common/top-bar/top-bar';
import { SlotFillProvider } from '@wordpress/components'; import { SlotFillProvider } from '@wordpress/components';
import { useSelect } from '@wordpress/data'; import { useSelect } from '@wordpress/data';
import { registerTranslations } from 'common'; import { registerTranslations } from 'common';
@ -72,7 +72,7 @@ function Automations(): JSX.Element {
return ( return (
<> <>
<TopBarWithBoundary /> <TopBarWithBeamer />
<GlobalNotices /> <GlobalNotices />
<Notices /> <Notices />
<MssAccessNotices /> <MssAccessNotices />

View File

@ -143,7 +143,6 @@ export function* activate() {
return { return {
type: 'ACTIVATE', type: 'ACTIVATE',
automation: data?.data ?? automation, automation: data?.data ?? automation,
saved: !!data?.data,
} as const; } as const;
} }

View File

@ -41,15 +41,11 @@ export function reducer(state: State, action): State {
savedState: 'saved', savedState: 'saved',
}; };
case 'ACTIVATE': case 'ACTIVATE':
return action.saved return {
? { ...state,
...state, automationData: action.automation,
automationData: action.automation, savedState: 'saved',
savedState: 'saved', };
}
: {
...state,
};
case 'DEACTIVATE': case 'DEACTIVATE':
return { return {
...state, ...state,

View File

@ -67,9 +67,7 @@ export function StatisticSeparator({
// in an empty if/else branch. To calculate the total we need to subtract // in an empty if/else branch. To calculate the total we need to subtract
// totalEntered of the sibling step from totalEntered of previousStep // totalEntered of the sibling step from totalEntered of previousStep
const siblingStep = previousStep.next_steps.find((step) => step.id); const siblingStep = previousStep.next_steps.find((step) => step.id);
const totalEnteredSibling = siblingStep const totalEnteredSibling = calculateTotals(siblingStep.id);
? calculateTotals(siblingStep.id)
: 0;
const totalEnteredPrevious = completed[previousStep.id] ?? 0; const totalEnteredPrevious = completed[previousStep.id] ?? 0;
totalEntered = totalEnteredPrevious - totalEnteredSibling; totalEntered = totalEnteredPrevious - totalEnteredSibling;
} else { } else {

View File

@ -2,7 +2,7 @@ import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { dispatch, select, useSelect } from '@wordpress/data'; import { dispatch, select, useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { TopBarWithBoundary } from '../../../../common/top-bar/top-bar'; import { TopBarWithBeamer } from '../../../../common/top-bar/top-bar';
import { Notices } from '../../../listing/components/notices'; import { Notices } from '../../../listing/components/notices';
import { Header } from './components/header'; import { Header } from './components/header';
import { Overview } from './components/overview'; import { Overview } from './components/overview';
@ -49,7 +49,7 @@ function TopBarWithBreadcrumb(): JSX.Element {
})); }));
return ( return (
<TopBarWithBoundary> <TopBarWithBeamer>
<p className="mailpoet-automation-analytics-title"> <p className="mailpoet-automation-analytics-title">
<a href={MailPoet.urls.automationListing}> <a href={MailPoet.urls.automationListing}>
{__('Automations', 'mailpoet')} {__('Automations', 'mailpoet')}
@ -57,7 +57,7 @@ function TopBarWithBreadcrumb(): JSX.Element {
<strong>{automation.name}</strong> <strong>{automation.name}</strong>
<AutomationStatus status={automation.status} /> <AutomationStatus status={automation.status} />
</p> </p>
</TopBarWithBoundary> </TopBarWithBeamer>
); );
} }

View File

@ -3,7 +3,6 @@ import { Step } from '../../../../../editor/components/automation/types';
import { storeName } from '../../../../../editor/store'; import { storeName } from '../../../../../editor/store';
const transactionalTriggers = [ const transactionalTriggers = [
'mailpoet:custom-trigger',
'woocommerce:order-status-changed', 'woocommerce:order-status-changed',
'woocommerce:order-created', 'woocommerce:order-created',
'woocommerce:order-completed', 'woocommerce:order-completed',
@ -16,9 +15,6 @@ const transactionalTriggers = [
'woocommerce-subscriptions:subscription-status-changed', 'woocommerce-subscriptions:subscription-status-changed',
'woocommerce-subscriptions:trial-ended', 'woocommerce-subscriptions:trial-ended',
'woocommerce-subscriptions:trial-started', 'woocommerce-subscriptions:trial-started',
'woocommerce:buys-from-a-tag',
'woocommerce:buys-from-a-category',
'woocommerce:buys-a-product',
]; ];
export function isTransactional(step: Step): boolean { export function isTransactional(step: Step): boolean {

View File

@ -7,7 +7,6 @@ import { step as AbandonedCartTrigger } from './steps/abandoned-cart';
import { MailPoet } from '../../../mailpoet'; import { MailPoet } from '../../../mailpoet';
import { step as BuysAProductTrigger } from './steps/buys-a-product'; import { step as BuysAProductTrigger } from './steps/buys-a-product';
import { step as BuysFromACategory } from './steps/buys-from-a-category'; import { step as BuysFromACategory } from './steps/buys-from-a-category';
import { step as BuysFromATag } from './steps/buys-from-a-tag';
import { step as MadeAReview } from './steps/made-a-review'; import { step as MadeAReview } from './steps/made-a-review';
// Insert new imports here // Insert new imports here
@ -22,7 +21,6 @@ export const initialize = (): void => {
registerStepType(AbandonedCartTrigger); registerStepType(AbandonedCartTrigger);
registerStepType(BuysAProductTrigger); registerStepType(BuysAProductTrigger);
registerStepType(BuysFromACategory); registerStepType(BuysFromACategory);
registerStepType(BuysFromATag);
registerStepType(MadeAReview); registerStepType(MadeAReview);
// Insert new steps here // Insert new steps here
}; };

View File

@ -1,86 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
import { PanelBody } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
import { dispatch, useSelect } from '@wordpress/data';
import { PlainBodyTitle } from '../../../../../editor/components';
import { storeName } from '../../../../../editor/store';
import { OrderStatusPanel } from '../../order-status-changed/edit/order-status-panel';
import autocompleter from './tag-autocompleter';
type Tag = {
key: string | number;
label?: string;
};
async function fetchTags(include: number[], callback: (tags: Tag[]) => void) {
const path = addQueryArgs('/wp/v2/product_tag/', { include });
const data: { id: number; name: string }[] = await apiFetch({
path,
method: 'GET',
});
callback(data.map((item) => ({ key: item?.id, label: item?.name })));
}
export function Edit(): JSX.Element {
const [current, setCurrent] = useState<Tag[]>([]);
const { selectedStep } = useSelect((select) => ({
selectedStep: select(storeName).getSelectedStep(),
}));
const tagIds: number[] = useMemo(
() => (selectedStep.args?.tag_ids as number[]) ?? [],
[selectedStep],
);
const [isBusy, setIsBusy] = useState(tagIds.length > 0);
useEffect(() => {
if (!isBusy) {
return;
}
void fetchTags(tagIds, (tags: Tag[]) => {
setCurrent(tags);
setIsBusy(false);
});
}, [isBusy, tagIds]);
return (
<>
<PanelBody opened>
<PlainBodyTitle title={__('Tags', 'mailpoet')} />
<Search
disabled={isBusy}
type="custom"
autocompleter={autocompleter}
className={`mailpoet-product-search ${isBusy ? 'is-busy' : ''}`}
placeholder={__('Search for a tag', 'mailpoet')}
selected={current}
onChange={(items: Tag[]) => {
setCurrent(items);
void dispatch(storeName).updateStepArgs(
selectedStep.id,
'tag_ids',
items.map((item) => item.key),
);
}}
multiple
inlineTags
/>
</PanelBody>
<OrderStatusPanel
label={__('Order settings', 'mailpoet')}
showFrom={false}
showTo
toLabel={__('Order status', 'mailpoet')}
onChange={(status, property) => {
void dispatch(storeName).updateStepArgs(
selectedStep.id,
property,
status,
);
}}
/>
</>
);
}

View File

@ -1,67 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { AutoCompleter } from '@woocommerce/components/build-types/search/autocompleters';
const tagAutoCompleter: AutoCompleter = {
name: 'tags',
className: 'woocommerce-search__product-result',
options(search) {
const query = search
? {
search,
per_page: 10,
orderby: 'count',
}
: {};
return apiFetch({
path: addQueryArgs('/wp/v2/product_tag', query),
});
},
isDebounced: true,
getOptionIdentifier(tag) {
return tag.id as number;
},
getOptionKeywords(tag) {
return [tag.name] as string[];
},
getFreeTextOptions(query) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{__('Search results', 'mailpoet')}
</span>
);
const titleOption = {
key: 'title',
label,
value: { id: query, name: query },
};
return [titleOption];
},
getOptionLabel(tag) {
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={tag.name}
>
{tag.name}
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion(tag) {
const value = {
key: tag.id,
label: tag.name,
};
return value;
},
};
export default tagAutoCompleter;

View File

@ -1,26 +0,0 @@
export function Icon(): JSX.Element {
return (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.73173 4.00134C5.8081 3.03517 6.61579 2.27551 7.60166 2.27551C8.58753 2.27551 9.39522 3.03517 9.47159 4.00134H10.1904C10.7479 4.00134 11.2033 4.45681 11.2033 5.01426V10.1917C11.2033 10.7492 10.7479 11.2047 10.1904 11.2047H5.01292C4.45547 11.2047 4 10.7492 4 10.1917V5.01426C4 4.45681 4.45547 4.00134 5.01292 4.00134H5.73173ZM7.60166 3.43843C7.36389 3.43843 7.15161 3.55664 7.02145 3.73861C6.96586 3.81633 6.92553 3.90533 6.90474 4.00134H8.29858C8.27779 3.90533 8.23746 3.81633 8.18187 3.73861C8.05171 3.55664 7.83943 3.43843 7.60166 3.43843ZM5.16291 5.16426V9.65285C5.16291 9.86763 5.33703 10.0417 5.55181 10.0417H9.65151C9.86629 10.0417 10.0404 9.86763 10.0404 9.65285V5.16426H9.47749V5.87717C9.47749 6.19732 9.21618 6.45863 8.89603 6.45863C8.57589 6.45863 8.31458 6.19732 8.31458 5.87717V5.16426H6.88875V5.87717C6.88875 6.19732 6.62743 6.45863 6.30729 6.45863C5.98714 6.45863 5.72583 6.19732 5.72583 5.87717V5.16426H5.16291Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.5858 4.58722C13.9609 4.21215 14.4696 4.00143 15 4.00143H18C18.5304 4.00143 19.0391 4.21215 19.4142 4.58722C19.7893 4.96229 20 5.471 20 6.00143V9.00143C20 9.53187 19.7893 10.0406 19.4142 10.4156C19.0391 10.7907 18.5304 11.0014 18 11.0014H15C14.4696 11.0014 13.9609 10.7907 13.5858 10.4156C13.2107 10.0406 13 9.53187 13 9.00143V6.00143C13 5.471 13.2107 4.96229 13.5858 4.58722ZM15 5.50143H18C18.1326 5.50143 18.2598 5.55411 18.3536 5.64788C18.4473 5.74165 18.5 5.86883 18.5 6.00143V9.00143C18.5 9.13404 18.4473 9.26122 18.3536 9.35499C18.2598 9.44876 18.1326 9.50143 18 9.50143H15C14.8674 9.50143 14.7402 9.44876 14.6464 9.35499C14.5527 9.26122 14.5 9.13404 14.5 9.00143V6.00143C14.5 5.86883 14.5527 5.74165 14.6464 5.64788C14.7402 5.55411 14.8674 5.50143 15 5.50143Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.5858 13.5844C13.9609 13.2093 14.4696 12.9986 15 12.9986H18C18.5304 12.9986 19.0391 13.2093 19.4142 13.5844C19.7893 13.9594 20 14.4681 20 14.9986V17.9986C20 18.529 19.7893 19.0377 19.4142 19.4128C19.0391 19.7879 18.5304 19.9986 18 19.9986H15C14.4696 19.9986 13.9609 19.7879 13.5858 19.4128C13.2107 19.0377 13 18.529 13 17.9986V14.9986C13 14.4681 13.2107 13.9594 13.5858 13.5844ZM15 14.4986H18C18.1326 14.4986 18.2598 14.5513 18.3536 14.645C18.4473 14.7388 18.5 14.866 18.5 14.9986V17.9986C18.5 18.1312 18.4473 18.2584 18.3536 18.3521C18.2598 18.4459 18.1326 18.4986 18 18.4986H15C14.8674 18.4986 14.7402 18.4459 14.6464 18.3521C14.5527 18.2584 14.5 18.1312 14.5 17.9986V14.9986C14.5 14.866 14.5527 14.7388 14.6464 14.645C14.7402 14.5513 14.8674 14.4986 15 14.4986Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.58579 13.5844C4.21071 13.9594 4 14.4681 4 14.9986V17.9986C4 18.529 4.21071 19.0377 4.58579 19.4128C4.96086 19.7879 5.46957 19.9986 6 19.9986H9C9.53043 19.9986 10.0391 19.7879 10.4142 19.4128C10.7893 19.0377 11 18.529 11 17.9986V14.9986C11 14.4681 10.7893 13.9594 10.4142 13.5844C10.0391 13.2093 9.53043 12.9986 9 12.9986H6C5.46957 12.9986 4.96086 13.2093 4.58579 13.5844ZM9 14.4986H6C5.86739 14.4986 5.74021 14.5513 5.64645 14.645C5.55268 14.7388 5.5 14.866 5.5 14.9986V17.9986C5.5 18.1312 5.55268 18.2584 5.64645 18.3521C5.74021 18.4459 5.86739 18.4986 6 18.4986H9C9.13261 18.4986 9.25979 18.4459 9.35355 18.3521C9.44732 18.2584 9.5 18.1312 9.5 17.9986V14.9986C9.5 14.866 9.44732 14.7388 9.35355 14.645C9.25979 14.5513 9.13261 14.4986 9 14.4986Z"
/>
</svg>
);
}

View File

@ -1,38 +0,0 @@
import { __ } from '@wordpress/i18n';
import { StepType } from '../../../../editor/store';
import { Edit } from './edit';
import { Icon } from './icon';
const keywords = [
// translators: noun, used as a search keyword for "Customer buys from a tag" trigger
__('tag', 'mailpoet'),
// translators: verb, used as a search keyword for "Customer buys from a tag" trigger
__('buy', 'mailpoet'),
// translators: verb, used as a search keyword for "Customer buys from a tag" trigger
__('purchase', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer buys from a tag" trigger
__('ecommerce', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer buys from a tag" trigger
__('woocommerce', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer buys from a tag" trigger
__('product', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer buys from a tag" trigger
__('order', 'mailpoet'),
];
export const step: StepType = {
key: 'woocommerce:buys-from-a-tag',
group: 'triggers',
title: () => __('Customer buys from a tag', 'mailpoet'),
description: () =>
__(
'Start the automation when a customer buys a product from a tag.',
'mailpoet',
),
subtitle: () => __('Trigger', 'mailpoet'),
keywords,
foreground: '#2271b1',
background: '#f0f6fc',
icon: () => <Icon />,
edit: () => <Edit />,
} as const;

View File

@ -4,27 +4,27 @@ import { Icon } from './icon';
import { PremiumModalForStepEdit } from '../../../../components/premium-modal-steps-edit'; import { PremiumModalForStepEdit } from '../../../../components/premium-modal-steps-edit';
const keywords = [ const keywords = [
// translators: noun, used as a search keyword for "Customer posts a review" trigger // translators: noun, used as a search keyword for "Customer makes a review" trigger
__('review', 'mailpoet'), __('review', 'mailpoet'),
// translators: verb, used as a search keyword for "Customer posts a review" trigger // translators: verb, used as a search keyword for "Customer makes a review" trigger
__('buy', 'mailpoet'), __('buy', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer posts a review" trigger // translators: noun, used as a search keyword for "Customer makes a review" trigger
__('comment', 'mailpoet'), __('comment', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer posts a review" trigger // translators: noun, used as a search keyword for "Customer makes a review" trigger
__('ecommerce', 'mailpoet'), __('ecommerce', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer posts a review" trigger // translators: noun, used as a search keyword for "Customer makes a review" trigger
__('woocommerce', 'mailpoet'), __('woocommerce', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer posts a review" trigger // translators: noun, used as a search keyword for "Customer makes a review" trigger
__('product', 'mailpoet'), __('product', 'mailpoet'),
// translators: noun, used as a search keyword for "Customer posts a review" trigger // translators: noun, used as a search keyword for "Customer makes a review" trigger
__('order', 'mailpoet'), __('order', 'mailpoet'),
]; ];
export const step: StepType = { export const step: StepType = {
key: 'woocommerce:made-a-review', key: 'woocommerce:made-a-review',
group: 'triggers', group: 'triggers',
title: () => __('Customer posts a review', 'mailpoet'), title: () => __('Customer makes a review', 'mailpoet'),
description: () => description: () =>
__('Start the automation when a customer posts a review.', 'mailpoet'), __('Start the automation when a customer makes a review.', 'mailpoet'),
subtitle: () => __('Trigger', 'mailpoet'), subtitle: () => __('Trigger', 'mailpoet'),
keywords, keywords,

View File

@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n';
import { registerTranslations } from 'common'; import { registerTranslations } from 'common';
import { automationTemplateCategories, automationTemplates } from './config'; import { automationTemplateCategories, automationTemplates } from './config';
import { initializeApi } from '../api'; import { initializeApi } from '../api';
import { TopBarWithBoundary } from '../../common/top-bar/top-bar'; import { TopBarWithBeamer } from '../../common/top-bar/top-bar';
import { FromScratchButton } from './components/from-scratch'; import { FromScratchButton } from './components/from-scratch';
import { BackButton, PageHeader } from '../../common/page-header'; import { BackButton, PageHeader } from '../../common/page-header';
import { MailPoet } from '../../mailpoet'; import { MailPoet } from '../../mailpoet';
@ -50,7 +50,7 @@ function Templates(): JSX.Element {
return ( return (
<div className="mailpoet-main-container"> <div className="mailpoet-main-container">
<TopBarWithBoundary /> <TopBarWithBeamer />
<PageHeader <PageHeader
heading={__('Start with a template', 'mailpoet')} heading={__('Start with a template', 'mailpoet')}
headingPrefix={ headingPrefix={

View File

@ -1,4 +1,4 @@
import { __, sprintf } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { PlacesType } from 'react-tooltip'; import { PlacesType } from 'react-tooltip';
import { Badge } from './badge'; import { Badge } from './badge';
@ -15,48 +15,48 @@ const getStats = () => ({
badgeRanges: [30, 10, 0], badgeRanges: [30, 10, 0],
badgeTypes: ['excellent', 'good', 'critical'], badgeTypes: ['excellent', 'good', 'critical'],
tooltipText: { tooltipText: {
// translators: Shows a percentage range, "above 30%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Excellent open rate
excellent: sprintf(__('above %s%%', 'mailpoet'), 30), excellent: __('above 30%', 'mailpoet'),
// translators: Shows a percentage range, "between 10% and 30%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Good open rate
good: sprintf(__('between %s%% and %s%%', 'mailpoet'), 10, 30), good: __('between 10 and 30%', 'mailpoet'),
// translators: Shows a percentage range, "below 10%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Critical open rate
critical: sprintf(__('below %s%%', 'mailpoet'), 10), critical: __('under 10%', 'mailpoet'),
}, },
}, },
clicked: { clicked: {
badgeRanges: [3, 1, 0], badgeRanges: [3, 1, 0],
badgeTypes: ['excellent', 'good', 'critical'], badgeTypes: ['excellent', 'good', 'critical'],
tooltipText: { tooltipText: {
// translators: Shows a percentage range, "above 30%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Excellent click rate
excellent: sprintf(__('above %s%%', 'mailpoet'), 3), excellent: __('above 3%', 'mailpoet'),
// translators: Shows a percentage range, "between 10% and 30%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Good click rate
good: sprintf(__('between %s%% and %s%%', 'mailpoet'), 1, 3), good: __('between 1 and 3%', 'mailpoet'),
// translators: Shows a percentage range, "below 10%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Critical click rate
critical: sprintf(__('below %s%%', 'mailpoet'), 1), critical: __('under 1%', 'mailpoet'),
}, },
}, },
bounced: { bounced: {
badgeRanges: [1.5, 0.5, 0], badgeRanges: [1.5, 0.5, 0],
badgeTypes: ['critical', 'good', 'excellent'], badgeTypes: ['critical', 'good', 'excellent'],
tooltipText: { tooltipText: {
// translators: Shows a percentage range, "below 10%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Excellent bounce rate
excellent: sprintf(__('below %s%%', 'mailpoet'), 0.5), excellent: __('below 0.5%', 'mailpoet'),
// translators: Shows a percentage range, "between 10% and 30%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Good bounce rate
good: sprintf(__('between %s%% and %s%%', 'mailpoet'), 0.5, 1.5), good: __('between 0.5% and 1.5%', 'mailpoet'),
// translators: Shows a percentage range, "above 30%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Critical bounce rate
critical: sprintf(__('above %s%%', 'mailpoet'), 1.5), critical: __('above 1.5%', 'mailpoet'),
}, },
}, },
unsubscribed: { unsubscribed: {
badgeRanges: [0.7, 0.3, 0], badgeRanges: [0.7, 0.3, 0],
badgeTypes: ['critical', 'good', 'excellent'], badgeTypes: ['critical', 'good', 'excellent'],
tooltipText: { tooltipText: {
// translators: Shows a percentage range, "below 10%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Excellent unsubscribe rate
excellent: sprintf(__('below %s%%', 'mailpoet'), 0.3), excellent: __('Below 0.3%', 'mailpoet'),
// translators: Shows a percentage range, "between 10% and 30%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Good unsubscribe rate
good: sprintf(__('between %s%% and %s%%', 'mailpoet'), 0.3, 0.7), good: __('between 0.3% and 0.7%', 'mailpoet'),
// translators: Shows a percentage range, "above 30%". Used in contexts like open, click, bounce, or unsubscribe rates. // translators: Critical unsubscribe rate
critical: sprintf(__('above %s%%', 'mailpoet'), 0.7), critical: __('above 0.7%', 'mailpoet'),
}, },
}, },
}); });

View File

@ -1,40 +1,21 @@
import { _x } from '@wordpress/i18n'; import { Input } from 'common/index';
import { Button, Input } from 'common/index';
import { useAction, useSelector } from 'settings/store/hooks'; import { useAction, useSelector } from 'settings/store/hooks';
import { useState } from 'react';
type KeyInputPropType = { type KeyInputPropType = {
placeholder?: string; placeholder?: string;
isFullWidth?: boolean; isFullWidth?: boolean;
forceRevealed?: boolean;
}; };
export function KeyInput({ export function KeyInput({
placeholder, placeholder,
isFullWidth = false, isFullWidth = false,
forceRevealed = false,
}: KeyInputPropType) { }: KeyInputPropType) {
const state = useSelector('getKeyActivationState')(); const state = useSelector('getKeyActivationState')();
const setState = useAction('updateKeyActivationState'); const setState = useAction('updateKeyActivationState');
const [isRevealed, setIsRevealed] = useState(false);
const inputType = forceRevealed || isRevealed ? 'text' : 'password';
const toggleButton = !forceRevealed && (
<Button
className="mailpoet-premium-key-toggle"
variant="tertiary"
onClick={() => setIsRevealed(!isRevealed)}
>
{isRevealed
? // translators: Used as a button to show or hide the premium key
_x('Hide', 'verb', 'mailpoet')
: // translators: Used as a button to show or hide the premium key
_x('Show', 'verb', 'mailpoet')}
</Button>
);
return ( return (
<Input <Input
type={inputType} type="text"
id="mailpoet_premium_key" id="mailpoet_premium_key"
name="premium[premium_key]" name="premium[premium_key]"
placeholder={placeholder} placeholder={placeholder}
@ -48,7 +29,6 @@ export function KeyInput({
key: event.target.value.trim() || null, key: event.target.value.trim() || null,
}) })
} }
iconEnd={toggleButton}
/> />
); );
} }

View File

@ -1,3 +1,4 @@
import { action } from '_storybook/action';
import { TopBar } from '../top-bar'; import { TopBar } from '../top-bar';
export default { export default {
@ -16,7 +17,7 @@ export function TopBarWithoutChildren() {
left: '0px', left: '0px',
}} }}
> >
<TopBar /> <TopBar hasNews={false} onBeamerClick={action('beamer click')} />
</div> </div>
); );
} }
@ -33,7 +34,7 @@ export function TopBarWithoutChildrenWithNews() {
left: '0px', left: '0px',
}} }}
> >
<TopBar /> <TopBar hasNews onBeamerClick={action('beamer click')} />
</div> </div>
); );
} }

View File

@ -1,3 +1,4 @@
import { action } from '_storybook/action';
import { TopBar } from '../top-bar'; import { TopBar } from '../top-bar';
import { Button } from '../../button/button'; import { Button } from '../../button/button';
@ -17,7 +18,7 @@ export function TopBarWithChildren() {
left: '0px', left: '0px',
}} }}
> >
<TopBar> <TopBar hasNews={false} onBeamerClick={action('beamer click')}>
<Button>Button</Button> <Button>Button</Button>
</TopBar> </TopBar>
</div> </div>

View File

@ -0,0 +1,22 @@
export function BeamerIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 17V14.5002L6.00101 14.4997C7.10049 13.9995 7.90003 12.8998 8 11.7002V9.50016C8 7.96615 8.78223 6.62769 10.0309 5.95845C10.3288 5.79879 10.6533 5.67721 11 5.60016C10.9 5.50016 10.8 5.30016 10.8 5.10016C10.8 5.01146 10.8109 4.92493 10.8315 4.84189C10.95 4.36325 11.3887 4.00017 11.9 4.00017C11.9334 4.00017 11.9672 3.99696 12 4C12.0332 3.99688 12.0661 4.00004 12.1 4.00004C12.6113 4.00004 13.05 4.36313 13.1685 4.84177C13.1891 4.92481 13.2 5.01133 13.2 5.10004C13.2 5.30004 13.1 5.50004 13 5.60004C13.3467 5.67709 13.6712 5.79867 13.9691 5.95833C15.2178 6.62757 16 7.96602 16 9.50004V11.7C16.1 12.9 16.9 14.0002 18 14.5002V17.0002L6 17ZM17 16H7V15.148C8.2117 14.4742 8.98499 13.2218 9.09655 11.8831L9.1 9.50004C9.1 8.15301 9.99683 6.94434 11.3676 6.56356L11.7403 6.46006L12.0003 6.40228L12.2553 6.45896L12.6324 6.56368C14.0032 6.94446 14.9 8.15313 14.9 9.50016L14.9035 11.8832C15.015 13.2219 15.7883 14.4743 17 15.1481V16ZM12.0001 5.3601C12.221 5.3601 12.4001 5.18102 12.4001 4.9601C12.4001 4.73919 12.221 4.5601 12.0001 4.5601C11.7792 4.5601 11.6001 4.73919 11.6001 4.9601C11.6001 5.18102 11.7792 5.3601 12.0001 5.3601Z"
fill="#2C3338"
/>
<path
d="M13.8303 19C14.0017 18.7054 14.1 18.3637 14.1 18L9.9 18.0002C9.9 18.3638 9.99834 18.7055 10.1697 19.0002C10.5169 19.5968 11.1636 20.0002 11.9 20.0002C11.9339 20.0002 11.9677 19.9993 12.0012 19.9976C12.034 19.9992 12.0669 20 12.1 20C12.8364 20 13.4831 19.5967 13.8303 19Z"
fill="#2C3338"
/>
</svg>
);
}

View File

@ -1,25 +1,62 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { withFeatureAnnouncement } from 'announcements/with-feature-announcement';
import { HideScreenOptions } from 'common/hide-screen-options/hide-screen-options'; import { HideScreenOptions } from 'common/hide-screen-options/hide-screen-options';
import { MailPoetLogoResponsive } from './mailpoet-logo-responsive'; import { MailPoetLogoResponsive } from './mailpoet-logo-responsive';
import { BeamerIcon } from './beamer-icon';
import { ScreenOptionsFix } from './screen-options-fix'; import { ScreenOptionsFix } from './screen-options-fix';
import { withBoundary } from '../error-boundary'; import { withBoundary } from '../error-boundary';
import { MailPoet } from '../../mailpoet';
type Props = { type Props = {
children?: ReactNode; children?: ReactNode;
hasNews?: boolean;
onBeamerClick?: () => void;
logoWithLink?: boolean; logoWithLink?: boolean;
hideScreenOptions?: boolean; hideScreenOptions?: boolean;
}; };
export function TopBar({ export function TopBar({
children, children,
hasNews,
onBeamerClick,
logoWithLink = true, logoWithLink = true,
hideScreenOptions = false, hideScreenOptions = false,
}: Props) { }: Props) {
const buttonClasses = classnames(
'mailpoet-top-bar-beamer',
hasNews ? 'mailpoet-top-bar-beamer-dot' : '',
);
return ( return (
<div className="mailpoet-top-bar"> <div className="mailpoet-top-bar">
<MailPoetLogoResponsive withLink={logoWithLink} /> <MailPoetLogoResponsive withLink={logoWithLink} />
<div className="mailpoet-top-bar-children">{children}</div> <div className="mailpoet-top-bar-children">{children}</div>
<div className="mailpoet-flex-grow" /> <div className="mailpoet-flex-grow" />
{onBeamerClick && MailPoet.libs3rdPartyEnabled && (
<div>
<a
role="button"
onClick={onBeamerClick}
className={buttonClasses}
title={__('Whats new', 'mailpoet')}
tabIndex={0}
onKeyDown={(event) => {
if (
['keydown', 'keypress'].includes(event.type) &&
['Enter', ' '].includes(event.key)
) {
event.preventDefault();
onBeamerClick();
}
}}
>
<BeamerIcon />
<span>{__('Updates', 'mailpoet')}</span>
</a>
<span id="beamer-empty-element" />
</div>
)}
<ScreenOptionsFix /> <ScreenOptionsFix />
{hideScreenOptions && <HideScreenOptions />} {hideScreenOptions && <HideScreenOptions />}
</div> </div>
@ -27,4 +64,4 @@ export function TopBar({
} }
TopBar.displayName = 'TopBar'; TopBar.displayName = 'TopBar';
export const TopBarWithBoundary = withBoundary(TopBar); export const TopBarWithBeamer = withFeatureAnnouncement(withBoundary(TopBar));

View File

@ -14,7 +14,7 @@
"inserter": false, "inserter": false,
"lock": false "lock": false
}, },
"apiVersion": 3, "apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json", "$schema": "https://schemas.wp.org/trunk/block.json",
"attributes": { "attributes": {
"logo": { "logo": {

View File

@ -2,14 +2,10 @@ import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor'; import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, RadioControl, Icon } from '@wordpress/components'; import { PanelBody, RadioControl, Icon } from '@wordpress/components';
import { useState } from 'react'; import { useSelect } from '@wordpress/data';
import metadata from './block.json'; import metadata from './block.json';
import { storeName } from '../../engine/store/constants';
import MailPoetIcon from './mailpoet-icon'; import MailPoetIcon from './mailpoet-icon';
import { PremiumModal } from '../../common/premium-modal';
import './style.scss';
const getCdnUrl = () => window.mailpoet_cdn_url;
const getPremiumPluginStatus = () => window.mailpoet_premium_active;
function LogoImage({ function LogoImage({
logoSrc, logoSrc,
@ -18,31 +14,8 @@ function LogoImage({
logoSrc: string; logoSrc: string;
style?: React.CSSProperties; style?: React.CSSProperties;
}): JSX.Element { }): JSX.Element {
const [isModalOpened, setIsModalOpened] = useState(false);
return ( return (
<> <img src={logoSrc} style={style} alt="Powered by MailPoet" width="100px" />
<button
type="button"
className="mailpoet-email-footer-credit"
onClick={() => setIsModalOpened(true)}
>
<img
src={logoSrc}
style={style}
alt="Powered by MailPoet"
width="100px"
/>
</button>
{!!isModalOpened && (
<PremiumModal onRequestClose={() => setIsModalOpened(false)}>
{__(
'A MailPoet logo will appear in the footer of all emails sent with the free version of MailPoet.',
'mailpoet',
)}
</PremiumModal>
)}
</>
); );
} }
@ -54,10 +27,13 @@ function Edit({
setAttributes: (value: { logo: string }) => void; setAttributes: (value: { logo: string }) => void;
}): JSX.Element { }): JSX.Element {
const blockProps = useBlockProps(); const blockProps = useBlockProps();
const { cdnUrl, isPremiumPluginActive } = useSelect(
const cdnUrl = getCdnUrl(); (select) => ({
const isPremiumPluginActive = getPremiumPluginStatus(); cdnUrl: select(storeName).getCdnUrl(),
isPremiumPluginActive: select(storeName).isPremiumPluginActive(),
}),
[],
);
if (isPremiumPluginActive) { if (isPremiumPluginActive) {
return null; return null;
} }
@ -91,6 +67,15 @@ function Edit({
) as unknown as string, ) as unknown as string,
value: 'light', value: 'light',
}, },
{
label: (
<LogoImage
logoSrc={`${cdnUrl}email-editor/logo-dark.png`}
style={{ background: '#000000' }}
/>
) as unknown as string,
value: 'dark',
},
]} ]}
onChange={(value) => { onChange={(value) => {
setAttributes({ setAttributes({

View File

@ -0,0 +1,21 @@
import { addFilter } from '@wordpress/hooks';
import { Block } from '@wordpress/blocks';
/**
* Disables Styles for button
* Currently we are not able to read these styles in renderer
*/
function enhanceButtonBlock() {
addFilter(
'blocks.registerBlockType',
'mailpoet-email-editor/change-button',
(settings: Block, name) => {
if (name === 'core/button') {
return { ...settings, styles: [] };
}
return settings;
},
);
}
export { enhanceButtonBlock };

View File

@ -0,0 +1,28 @@
import { addFilter } from '@wordpress/hooks';
import { Block } from '@wordpress/blocks';
/**
* Switch layout to reduced flex email layout
* Email render engine can't handle full flex layout se we need to switch to reduced flex layout
*/
function enhanceButtonsBlock() {
addFilter(
'blocks.registerBlockType',
'mailpoet-email-editor/change-buttons',
(settings: Block, name) => {
if (name === 'core/buttons') {
return {
...settings,
supports: {
...settings.supports,
layout: false, // disable block editor's layouts
__experimentalEmailFlexLayout: true, // enable MailPoet's reduced flex email layout
},
};
}
return settings;
},
);
}
export { enhanceButtonsBlock };

View File

@ -0,0 +1,25 @@
import { addFilter } from '@wordpress/hooks';
import { Block } from '@wordpress/blocks';
function enhanceColumnBlock() {
addFilter(
'blocks.registerBlockType',
'mailpoet-email-editor/change-column',
(settings: Block, name) => {
if (name === 'core/column') {
return {
...settings,
supports: {
...settings.supports,
background: {
backgroundImage: true,
},
},
};
}
return settings;
},
);
}
export { enhanceColumnBlock };

View File

@ -0,0 +1,87 @@
import { InspectorControls } from '@wordpress/block-editor';
import { createHigherOrderComponent } from '@wordpress/compose';
import { Block } from '@wordpress/blocks';
import { addFilter } from '@wordpress/hooks';
const columnsEditCallback = createHigherOrderComponent(
(BlockEdit) =>
function alterBlocksEdits(props) {
if (props.name !== 'core/columns') {
return <BlockEdit {...props} />;
}
// CSS sets opacity by the class is-disabled by the toggle component from the Gutenberg package
// To deactivating the input we use CSS pointer-events because we want to avoid JavaScript hacks
const deactivateToggleCss = `
.components-panel__body .components-toggle-control .components-form-toggle { opacity: 0.3; }
.components-panel__body .components-toggle-control .components-form-toggle__input { pointer-events: none; }
.components-panel__body .components-toggle-control label { pointer-events: none; }
`;
return (
<>
<BlockEdit {...props} />
<InspectorControls>
<style>{deactivateToggleCss}</style>
</InspectorControls>
</>
);
},
'columnsEditCallback',
);
function deactivateStackOnMobile() {
addFilter(
'editor.BlockEdit',
'mailpoet-email-editor/deactivate-stack-on-mobile',
columnsEditCallback,
);
}
/**
* Disables layout support for columns and column blocks because
* the default layout `flex` add gaps between columns that it is not possible to support in emails.
*/
function disableColumnsLayout() {
addFilter(
'blocks.registerBlockType',
'mailpoet-email-editor/disable-columns-layout',
(settings, name) => {
if (name === 'core/columns' || name === 'core/column') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...settings,
supports: {
...settings.supports,
layout: false,
},
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return settings;
},
);
}
function enhanceColumnsBlock() {
addFilter(
'blocks.registerBlockType',
'mailpoet-email-editor/change-columns',
(settings: Block, name) => {
if (name === 'core/columns') {
return {
...settings,
supports: {
...settings.supports,
background: {
backgroundImage: true,
},
},
};
}
return settings;
},
);
}
export { deactivateStackOnMobile, disableColumnsLayout, enhanceColumnsBlock };

View File

@ -1,11 +1,8 @@
/**
* External dependencies
*/
import { addFilter } from '@wordpress/hooks'; import { addFilter } from '@wordpress/hooks';
import { import {
Block as WPBlock, Block as WPBlock,
BlockSupports as WPBlockSupports, BlockSupports as WPBlockSupports,
} from '@wordpress/blocks/index'; } from '@wordpress/blocks';
// Extend the BlockSupports type to include shadow // Extend the BlockSupports type to include shadow
// The shadow is not included in WP6.4 but it is in WP6.5 // The shadow is not included in WP6.4 but it is in WP6.5
@ -18,19 +15,19 @@ type Block = WPBlock & { supports?: BlockSupports };
* Currently we are not able to read these styles in renderer * Currently we are not able to read these styles in renderer
*/ */
function alterSupportConfiguration() { function alterSupportConfiguration() {
addFilter( addFilter(
'blocks.registerBlockType', 'blocks.registerBlockType',
'mailpoet-email-editor/block-support', 'mailpoet-email-editor/block-support',
( settings: Block ) => { (settings: Block) => {
if ( settings.supports?.shadow ) { if (settings.supports?.shadow) {
return { return {
...settings, ...settings,
supports: { ...settings.supports, shadow: false }, supports: { ...settings.supports, shadow: false },
}; };
} }
return settings; return settings;
} },
); );
} }
export { alterSupportConfiguration }; export { alterSupportConfiguration };

View File

@ -0,0 +1,30 @@
import { addFilter } from '@wordpress/hooks';
/**
* Disables layout support for group blocks because the default layout `flex` add gaps between columns that it is not possible to support in emails.
*/
function disableGroupVariations() {
addFilter(
'blocks.registerBlockType',
'mailpoet-email-editor/disable-group-variations',
(settings, name) => {
if (name === 'core/group') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...settings,
variations: settings.variations.filter(
(variation) => variation.name === 'group',
),
supports: {
...settings.supports,
layout: false,
},
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return settings;
},
);
}
export { disableGroupVariations };

View File

@ -0,0 +1,61 @@
import { InspectorControls } from '@wordpress/block-editor';
import { createHigherOrderComponent } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
import { Block } from '@wordpress/blocks';
const imageEditCallback = createHigherOrderComponent(
(BlockEdit) =>
function alterBlocksEdits(props) {
if (props.name !== 'core/image') {
return <BlockEdit {...props} />;
}
// Because we cannot support displaying the modal with image after clicking in the email we have to hide the toggle
const deactivateToggleCss = `
.components-tools-panel .components-toggle-control { display: none; }
`;
return (
<>
<BlockEdit {...props} />
<InspectorControls>
<style>{deactivateToggleCss}</style>
</InspectorControls>
</>
);
},
'imageEditCallback',
);
/**
* Because CSS property filter is not supported in almost 50% of email clients we have to disable it
*/
function disableImageFilter() {
addFilter(
'blocks.registerBlockType',
'mailpoet-email-editor/deactivate-image-filter',
(settings: Block, name) => {
if (name === 'core/image') {
return {
...settings,
supports: {
...settings.supports,
filter: {
duetone: false,
},
},
};
}
return settings;
},
);
}
function hideExpandOnClick() {
addFilter(
'editor.BlockEdit',
'mailpoet-email-editor/hide-expand-on-click',
imageEditCallback,
);
}
export { hideExpandOnClick, disableImageFilter };

View File

@ -0,0 +1,58 @@
import { addFilter } from '@wordpress/hooks';
import { Block } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
function Placeholder({ layoutClassNames }) {
const blockProps = useBlockProps({ className: layoutClassNames });
return (
<div {...blockProps}>
<p>{__('This is the Content block.', 'mailpoet')}</p>
<p>
{__(
'It will display all the blocks in the email content, which might be only simple text paragraphs. You can enrich your message with images, incorporate data through tables, explore different layout designs with columns, or use any other block type.',
'mailpoet',
)}
</p>
</div>
);
}
// Curried function to add a custom placeholder to the post content block, or just use the original Edit component.
function PostContentEdit(OriginalEditComponent) {
return function Edit({
context,
__unstableLayoutClassNames: layoutClassNames,
}) {
const { postId: contextPostId, postType: contextPostType } = context;
const hasContent = contextPostId && contextPostType;
if (hasContent) {
return (
<OriginalEditComponent
{...{ context, __unstableLayoutClassNames: layoutClassNames }}
/>
);
}
return <Placeholder layoutClassNames={layoutClassNames} />;
};
}
function enhancePostContentBlock() {
addFilter(
'blocks.registerBlockType',
'mailpoet-email-editor/change-post-content',
(settings: Block, name) => {
if (name === 'core/post-content') {
return {
...settings,
edit: PostContentEdit(settings.edit),
};
}
return settings;
},
);
}
export { enhancePostContentBlock };

View File

@ -0,0 +1,18 @@
import { unregisterFormatType } from '@wordpress/rich-text';
/**
* Disable Rich text formats we currently cannot support
* Note: This will remove its support for all blocks in the email editor e.g., p, h1,h2, etc
*/
function disableCertainRichTextFormats() {
// remove support for inline image - We can't use it
unregisterFormatType('core/image');
// remove support for Inline code - Not well formatted
unregisterFormatType('core/code');
// remove support for Language - Not supported for now
unregisterFormatType('core/language');
}
export { disableCertainRichTextFormats };

View File

@ -0,0 +1,30 @@
import { registerCoreBlocks } from '@wordpress/block-library';
import { enhanceColumnBlock } from './core/column';
import {
disableColumnsLayout,
deactivateStackOnMobile,
enhanceColumnsBlock,
} from './core/columns';
import { enhancePostContentBlock } from './core/post-content';
import { disableGroupVariations } from './core/group';
import { disableImageFilter, hideExpandOnClick } from './core/image';
import { disableCertainRichTextFormats } from './core/rich-text';
import { enhanceButtonBlock } from './core/button';
import { enhanceButtonsBlock } from './core/buttons';
import { alterSupportConfiguration } from './core/general-block-support';
export function initBlocks() {
deactivateStackOnMobile();
hideExpandOnClick();
disableImageFilter();
disableCertainRichTextFormats();
disableColumnsLayout();
disableGroupVariations();
enhanceButtonBlock();
enhanceButtonsBlock();
enhanceColumnBlock();
enhanceColumnsBlock();
enhancePostContentBlock();
alterSupportConfiguration();
registerCoreBlocks();
}

View File

@ -0,0 +1,39 @@
import { useDispatch, useSelect } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import { storeName } from '../../store';
/**
* This component is simplified version of the original one from @wordpress/editor package.
* The original component can be found here: https://github.com/WordPress/gutenberg/blob/46446b853d740c309c0675c7bf2ca4170a618c42/packages/editor/src/components/autosave-monitor/index.js
* The main reason for the own solution is that the original component needs to initialize the @wordpress/editor store.
* We could use the action `setEditedPost` from the editor package, but it is only in a newer version of the editor package.
*/
export function AutosaveMonitor() {
const { hasEdits, autosaveInterval } = useSelect(
(select) => ({
hasEdits: select(storeName).hasEdits(),
autosaveInterval: select(storeName).getAutosaveInterval(),
}),
[],
);
const { saveEditedEmail } = useDispatch(storeName);
useEffect(() => {
let autosaveTimer: NodeJS.Timeout | undefined;
if (hasEdits && autosaveInterval > 0) {
autosaveTimer = setTimeout(() => {
void saveEditedEmail();
}, autosaveInterval * 1000);
}
return () => {
if (autosaveTimer) {
clearTimeout(autosaveTimer);
}
};
}, [hasEdits, autosaveInterval, saveEditedEmail]);
return null;
}

View File

@ -0,0 +1,127 @@
/**
* WordPress dependencies
*/
import { useSelect } from '@wordpress/data';
import {
ErrorBoundary,
PostLockedModal,
EditorProvider,
} from '@wordpress/editor';
import { useMemo } from '@wordpress/element';
import { SlotFillProvider, Spinner } from '@wordpress/components';
import { Post, store as coreStore } from '@wordpress/core-data';
import { storeName } from '../../store';
import { unlock } from '../../../lock-unlock';
/**
* Internal dependencies
*/
import { Layout } from './layout';
import { useNavigateToEntityRecord } from '../../hooks/use-navigate-to-entity-record';
export function InnerEditor({
postId: initialPostId,
postType: initialPostType,
settings,
initialEdits,
...props
}) {
const {
currentPost,
onNavigateToEntityRecord,
onNavigateToPreviousEntityRecord,
} = useNavigateToEntityRecord(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
initialPostId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
initialPostType,
'post-only',
);
const { post, template } = useSelect(
(select) => {
const { getEntityRecord } = select(coreStore);
const { getEditedPostTemplate } = select(storeName);
const postObject = getEntityRecord(
'postType',
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
currentPost.postType,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
currentPost.postId,
);
return {
template:
currentPost.postType !== 'wp_template'
? getEditedPostTemplate()
: null,
post: postObject,
};
},
[currentPost.postType, currentPost.postId],
);
/*
* We need to fetch patterns ourselves. Automatic fetching of patterns is currently a private functionality
* that is not available in EditorProvider but only in the ExperimentalBlockEditorProvider which
* is not exported in the public components nor private components.
*/
const blockPatterns = useSelect(
(select) => {
const { hasFinishedResolution, getBlockPatternsForPostType } = unlock(
select(coreStore),
);
const patterns = getBlockPatternsForPostType(
currentPost.postType,
) as Post[];
return hasFinishedResolution('getBlockPatterns') ? patterns : undefined;
},
[currentPost.postType],
);
const editorSettings = useMemo(
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
() => ({
...settings,
onNavigateToEntityRecord,
onNavigateToPreviousEntityRecord,
defaultRenderingMode: 'template-locked',
supportsTemplateMode: true,
__experimentalBlockPatterns: blockPatterns,
}),
[
settings,
onNavigateToEntityRecord,
onNavigateToPreviousEntityRecord,
blockPatterns,
],
);
if (!post || (currentPost.postType !== 'wp_template' && !template)) {
return (
<div className="spinner-container">
<Spinner style={{ width: '80px', height: '80px' }} />
</div>
);
}
return (
<SlotFillProvider>
<EditorProvider
settings={editorSettings}
post={post}
initialEdits={initialEdits}
useSubRegistry={false}
// @ts-expect-error __unstableTemplate is not in the EditorProvider props in the installed version of packages
__unstableTemplate={template}
{...props}
>
{/* @ts-expect-error ErrorBoundary type is incorrect there is no onError */}
<ErrorBoundary>
<Layout />
<PostLockedModal />
</ErrorBoundary>
</EditorProvider>
</SlotFillProvider>
);
}

View File

@ -0,0 +1,118 @@
@import '~@wordpress/base-styles/colors';
#mailpoet-email-editor {
.editor-header__toolbar {
flex-grow: 1;
}
.editor-header__center {
flex-grow: 3;
}
}
// Specific styles for the component EmailTypeInfo
// Styles are based on the Block Card component from Gutenberg block editor
.mailpoet-email-sidebar__email-type-info {
.components-panel__row {
align-items: flex-start;
}
.mailpoet-email-type-info__icon {
flex: 0 0 24px;
margin-left: 0;
margin-right: 12px;
}
.mailpoet-email-type-info__content {
flex-grow: 1;
margin-bottom: 4px;
h2 {
font-size: 13px;
line-height: 24px;
margin: 0 0 4px;
}
span {
font-size: 13px;
}
}
}
.mailpoet-email-editor__settings-panel {
.mailpoet-settings-panel__subject .components-base-control__label {
width: 100%;
.components-external-link {
float: right;
}
}
.mailpoet-settings-panel__help {
margin-bottom: 20px;
.components-text {
color: #757575;
}
}
.mailpoet-settings-panel__preview-text .components-base-control__label {
width: 100%;
}
.mailpoet-settings-panel__preview-text-length {
color: $black;
display: inline-block;
float: right;
padding: 3px;
}
.mailpoet-settings-panel__preview-text-length-warning {
color: $alert-yellow;
}
.mailpoet-settings-panel__preview-text-length-error {
color: $alert-red;
}
}
.edit-post-visual-editor {
line-height: 1.4; /* Recommended line-height that is also used in the email editor */
margin: 0;
min-height: 100%;
padding: 0;
-webkit-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */
-ms-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */
word-spacing: normal;
}
.visual-editor__email_layout_wrapper {
height: 100%;
margin: 0 auto;
padding: 0;
width: 100%;
}
.visual-editor__email_content_wrapper {
display: flex;
height: 100%;
width: 100%;
> div {
width: 100%;
}
// Fix for mobile preview height
&.is-mobile-preview {
> div {
display: block !important;
}
.editor-styles-wrapper {
height: auto !important;
}
}
}
// Hide the advanced settings in the sidebar. This panel is not used in the email editor at this moment.
.block-editor-block-inspector__advanced {
display: none;
}

View File

@ -1,2 +1,4 @@
import './index.scss';
export * from './editor'; export * from './editor';
export * from './layout'; export * from './layout';

View File

@ -0,0 +1,128 @@
import { BlockSelectionClearer } from '@wordpress/block-editor';
import { UnsavedChangesWarning } from '@wordpress/editor';
import { uploadMedia } from '@wordpress/media-utils';
import classnames from 'classnames';
import { useSelect, useDispatch } from '@wordpress/data';
import {
ComplementaryArea,
FullscreenMode,
InterfaceSkeleton,
} from '@wordpress/interface';
import { useRef } from '@wordpress/element';
import './index.scss';
import { store as coreStore } from '@wordpress/core-data';
import { storeName } from '../../store';
import { useEmailCss } from '../../hooks';
import { AutosaveMonitor } from '../autosave';
import { BlockCompatibilityWarnings, Sidebar } from '../sidebar';
import { Header } from '../header';
import { ListviewSidebar } from '../listview-sidebar/listview-sidebar';
import { InserterSidebar } from '../inserter-sidebar/inserter-sidebar';
import { EditorNotices, SentEmailNotice } from '../notices';
import { StylesSidebar } from '../styles-sidebar';
import { VisualEditor } from './visual-editor/visual-editor';
import { TemplateSelection } from '../template-select';
export function Layout() {
const {
isFullscreenActive,
isSidebarOpened,
initialSettings,
previewDeviceType,
isInserterSidebarOpened,
isListviewSidebarOpened,
canUserEditMedia,
hasFixedToolbar,
focusMode,
styles,
} = useSelect(
(select) => ({
isFullscreenActive: select(storeName).isFeatureActive('fullscreenMode'),
isSidebarOpened: select(storeName).isSidebarOpened(),
isInserterSidebarOpened: select(storeName).isInserterSidebarOpened(),
isListviewSidebarOpened: select(storeName).isListviewSidebarOpened(),
initialSettings: select(storeName).getInitialEditorSettings(),
previewDeviceType: select(storeName).getDeviceType(),
canUserEditMedia: select(coreStore).canUser('create', 'media'),
hasFixedToolbar: select(storeName).isFeatureActive('fixedToolbar'),
focusMode: select(storeName).isFeatureActive('focusMode'),
styles: select(storeName).getStyles(),
}),
[],
);
const { toggleInserterSidebar } = useDispatch(storeName);
const [emailCss] = useEmailCss();
const className = classnames('edit-post-layout', {
'is-sidebar-opened': isSidebarOpened,
});
const contentRef = useRef(null);
// Styles for the canvas. Based on template-canvas.php, this equates to the body element.
const canvasStyles = {
background:
previewDeviceType === 'Desktop' ? styles.color.background : 'transparent',
fontFamily: styles.typography.fontFamily,
transition: 'all 0.3s ease 0s',
};
const settings = {
...initialSettings,
mediaUpload: canUserEditMedia ? uploadMedia : null,
hasFixedToolbar,
focusMode,
};
return (
<>
<FullscreenMode isActive={isFullscreenActive} />
<UnsavedChangesWarning />
<AutosaveMonitor />
<SentEmailNotice />
<Sidebar />
<StylesSidebar />
<TemplateSelection />
<InterfaceSkeleton
className={className}
header={<Header />}
editorNotices={<EditorNotices />}
content={
<>
<EditorNotices />
<BlockSelectionClearer
className="edit-post-visual-editor"
style={canvasStyles}
onClick={() => {
// Clear inserter sidebar when canvas is clicked.
if (isInserterSidebarOpened) {
void toggleInserterSidebar();
}
}}
>
<div className="visual-editor__email_content_wrapper">
<VisualEditor
disableIframe={false}
styles={[...settings.styles, ...emailCss]}
className="has-global-padding"
contentRef={contentRef}
iframeProps={{}}
/>
</div>
</BlockSelectionClearer>
</>
}
sidebar={<ComplementaryArea.Slot scope={storeName} />}
secondarySidebar={
(isInserterSidebarOpened && <InserterSidebar />) ||
(isListviewSidebarOpened && <ListviewSidebar />)
}
/>
{/* Rendering Warning component here ensures that the warning is displayed under the border configuration. */}
<BlockCompatibilityWarnings />
</>
);
}

View File

@ -0,0 +1,72 @@
/**
* WordPress dependencies
*/
import { useSelect } from '@wordpress/data';
import { useEffect, useState } from '@wordpress/element';
import { store as editorStore } from '@wordpress/editor';
import { __ } from '@wordpress/i18n';
import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components';
/**
* Component that:
*
* - Displays a 'Edit your template to edit this block' dialog when the user
* is focusing on editing email content and double clicks on a disabled
* template block.
*
* @see https://github.com/WordPress/gutenberg/blob/c754c783a9004db678fcfebd9a21a22820f2115c/packages/editor/src/components/visual-editor/edit-template-blocks-notification.js
*
* @param {Object} props
* @param {import('react').RefObject<HTMLElement>} props.contentRef Ref to the block
* editor iframe canvas.
*/
export default function EditTemplateBlocksNotification({ contentRef }) {
const { onNavigateToEntityRecord, templateId } = useSelect((select) => {
// @ts-expect-error getCurrentTemplateId is missing in types.
const { getEditorSettings, getCurrentTemplateId } = select(editorStore);
return {
// @ts-expect-error onNavigateToEntityRecord is missing in EditorSettings.
onNavigateToEntityRecord: getEditorSettings().onNavigateToEntityRecord,
templateId: getCurrentTemplateId(),
};
}, []);
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
const handleDblClick = (event) => {
if (!event.target.classList.contains('is-root-container')) {
return;
}
setIsDialogOpen(true);
};
const canvas = contentRef.current;
canvas?.addEventListener('dblclick', handleDblClick);
return () => {
canvas?.removeEventListener('dblclick', handleDblClick);
};
}, [contentRef]);
return (
<ConfirmDialog
isOpen={isDialogOpen}
confirmButtonText={__('Edit template')}
onConfirm={() => {
setIsDialogOpen(false);
onNavigateToEntityRecord({
postId: templateId,
postType: 'wp_template',
});
}}
onCancel={() => setIsDialogOpen(false)}
size="medium"
>
{__(
'Youve tried to select a block that is part of a template, which may be used on other emails. Would you like to edit the template?',
'mailpoet',
)}
</ConfirmDialog>
);
}

View File

@ -0,0 +1,94 @@
/**
* WordPress dependencies
*/
import { useRefEffect } from '@wordpress/compose';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import { unlock } from '../../../../lock-unlock';
const DISTANCE_THRESHOLD = 500;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function distanceFromRect(x: number, y: number, rect) {
const dx = x - clamp(x, Number(rect.left), Number(rect.right));
const dy = y - clamp(y, Number(rect.top), Number(rect.bottom));
return Math.sqrt(dx * dx + dy * dy);
}
export default function useSelectNearestEditableBlock({
isEnabled = true,
} = {}) {
const { getEnabledClientIdsTree, getBlockName, getBlockOrder } = unlock(
useSelect(blockEditorStore),
);
const { selectBlock } = useDispatch(blockEditorStore);
return useRefEffect(
(element: Element) => {
if (!isEnabled) {
return null;
}
const selectNearestEditableBlock = (x, y) => {
const editableBlockClientIds = getEnabledClientIdsTree().flatMap(
({ clientId }) => {
const blockName = getBlockName(clientId);
if (blockName === 'core/template-part') {
return [];
}
if (blockName === 'core/post-content') {
const innerBlocks = getBlockOrder(clientId);
if (innerBlocks.length) {
return innerBlocks as string[];
}
}
return [clientId as string];
},
);
// Extract the nearest client ID
const nearestClientIdData = editableBlockClientIds.reduce(
(acc: { clientId: string; distance: number }, clientId: string) => {
const block = element.querySelector(`[data-block="${clientId}"]`);
if (!block) {
return acc;
}
const rect = block.getBoundingClientRect();
const distance = distanceFromRect(Number(x), Number(y), rect);
if (distance < acc.distance && distance < DISTANCE_THRESHOLD) {
return { clientId, distance };
}
return acc;
},
{ clientId: null, distance: Number.POSITIVE_INFINITY },
);
const nearestClientId = nearestClientIdData?.clientId || '';
if (nearestClientId) {
void selectBlock(nearestClientId as string);
}
};
const handleClick = (event) => {
const shouldSelect =
event.target === element ||
event.target.classList.contains('is-root-container');
if (shouldSelect) {
selectNearestEditableBlock(event.clientX, event.clientY);
}
};
element.addEventListener('click', handleClick);
return () => element.removeEventListener('click', handleClick);
},
[isEnabled],
);
}

View File

@ -0,0 +1,201 @@
/**
* WordPress dependencies
*/
import classnames from 'classnames';
import {
BlockList,
// @ts-expect-error No types for this exist yet.
__unstableUseTypewriter as useTypewriter,
// @ts-expect-error No types for this exist yet.
RecursionProvider,
// @ts-expect-error No types for this exist yet.
privateApis as blockEditorPrivateApis,
// @ts-expect-error No types for this exist yet.
__experimentalUseResizeCanvas as useResizeCanvas,
} from '@wordpress/block-editor';
import { useRef } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useMergeRefs } from '@wordpress/compose';
import { store as editorStore } from '@wordpress/editor';
/**
* Internal dependencies
*/
import EditTemplateBlocksNotification from './edit-template-blocks-notification';
import useSelectNearestEditableBlock from './use-select-nearest-editable-block';
import { unlock } from '../../../../lock-unlock';
export const TEMPLATE_POST_TYPE = 'wp_template';
export const TEMPLATE_PART_POST_TYPE = 'wp_template_part';
export const PATTERN_POST_TYPE = 'wp_block';
export const NAVIGATION_POST_TYPE = 'wp_navigation';
const { ExperimentalBlockCanvas: BlockCanvas, useFlashEditableBlocks } = unlock(
blockEditorPrivateApis,
);
/**
* These post types have a special editor where they don't allow you to fill the title
* and they don't apply the layout styles.
*/
const DESIGN_POST_TYPES = [
PATTERN_POST_TYPE,
TEMPLATE_POST_TYPE,
NAVIGATION_POST_TYPE,
TEMPLATE_PART_POST_TYPE,
];
/**
* Copied and simplified from https://github.com/WordPress/gutenberg/blob/c754c783a9004db678fcfebd9a21a22820f2115c/packages/editor/src/components/visual-editor/index.js
* Simplifications:
* - doesn't support post-only mode for no design post types. We use the post-only mode only for templates
* - removed logic for layout styles. We currently pass them among all styles in styles property
* - removed support for post title
* - removed support for zooming
* - removed support for resizing
* @todo Need to fix layout so that we support Align settings properly
*/
export function VisualEditor({
// Ideally as we unify post and site editors, we won't need these props
styles,
disableIframe = false,
iframeProps,
contentRef,
className,
}) {
const {
renderingMode,
wrapperBlockName,
wrapperUniqueId,
deviceType,
isFocusedEntity,
layout,
} = useSelect((select) => {
const {
getCurrentPostId,
getCurrentPostType,
getEditorSettings,
// @ts-expect-error No types for this exist yet.
getRenderingMode,
// @ts-expect-error No types for this exist yet.
getDeviceType,
} = select(editorStore);
const postTypeSlug = getCurrentPostType();
const checkRenderingMode = getRenderingMode();
let checkWrapperBlockName;
if (postTypeSlug === PATTERN_POST_TYPE) {
checkWrapperBlockName = 'core/block';
} else if (checkRenderingMode === 'post-only') {
checkWrapperBlockName = 'core/post-content';
}
const editorSettings = getEditorSettings();
return {
renderingMode: checkRenderingMode,
isDesignPostType: DESIGN_POST_TYPES.includes(postTypeSlug),
// Post template fetch returns a 404 on classic themes, which
// messes with e2e tests, so check it's a block theme first.
wrapperBlockName: checkWrapperBlockName,
wrapperUniqueId: getCurrentPostId(),
deviceType: getDeviceType() as string,
// @ts-expect-error No types for this exist yet.
isFocusedEntity: !!editorSettings.onNavigateToPreviousEntityRecord,
postType: postTypeSlug,
// @ts-expect-error No types for this exist yet.
// eslint-disable-next-line no-underscore-dangle
isPreview: editorSettings.__unstableIsPreviewMode,
// @ts-expect-error There are no types for the experimental features settings.
// eslint-disable-next-line no-underscore-dangle
layout: editorSettings.__experimentalFeatures.layout,
};
}, []);
const deviceStyles = useResizeCanvas(deviceType);
// We want to use the same layout.
const blockListLayout = layout;
const localRef = useRef();
const typewriterRef = useTypewriter();
const newContentRef = useMergeRefs([
localRef,
contentRef,
renderingMode === 'post-only' ? typewriterRef : null,
useFlashEditableBlocks({
isEnabled: renderingMode === 'template-locked',
}),
useSelectNearestEditableBlock({
isEnabled: renderingMode === 'template-locked',
}),
]);
const shouldIframe =
!disableIframe || ['Tablet', 'Mobile'].includes(deviceType);
const containerWidth =
deviceType === 'Desktop' ? (layout.contentSize as string) : '100%';
const iframeStyles = [
...((styles as string[]) ?? []),
{
css: `.is-root-container{display:flow-root; width:${containerWidth}; margin: 0 auto;box-sizing: border-box;}`,
},
];
return (
<div
className={classnames(
'editor-visual-editor',
// this class is here for backward compatibility reasons.
'edit-post-visual-editor',
className as string,
{
'has-padding': isFocusedEntity,
'is-iframed': shouldIframe,
},
)}
>
<BlockCanvas
shouldIframe={shouldIframe}
contentRef={newContentRef}
styles={iframeStyles}
height="100%"
iframeProps={{
...iframeProps,
style: {
...iframeProps?.style,
...deviceStyles,
},
}}
>
<RecursionProvider
blockName={wrapperBlockName}
uniqueId={wrapperUniqueId}
>
<BlockList
className={classnames(
`is-${deviceType.toLowerCase()}-preview`,
'has-global-padding', // Ensures that padding is applied at the top level
)}
// @ts-expect-error No types for this exist yet.
layout={blockListLayout}
dropZoneElement={
// When iframed, pass in the html element of the iframe to
// ensure the drop zone extends to the edges of the iframe.
// @ts-expect-error No types for this exist yet.
disableIframe ? localRef.current : localRef.current?.parentNode
}
__unstableDisableDropZone={
// In template preview mode, disable drop zones at the root of the template.
renderingMode === 'template-locked'
}
/>
{renderingMode === 'template-locked' && (
<EditTemplateBlocksNotification contentRef={localRef} />
)}
</RecursionProvider>
</BlockCanvas>
</div>
);
}

View File

@ -0,0 +1,85 @@
import { useRef } from '@wordpress/element';
import {
Button,
Dropdown,
VisuallyHidden,
__experimentalText as Text,
TextControl,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { chevronDown } from '@wordpress/icons';
import { useSelect } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { storeName } from '../../store';
// @see https://github.com/WordPress/gutenberg/blob/5e0ffdbc36cb2e967dfa6a6b812a10a2e56a598f/packages/edit-post/src/components/header/document-actions/index.js
export function CampaignName() {
const { showIconLabels } = useSelect(
(select) => ({
showIconLabels: select(storeName).isFeatureActive('showIconLabels'),
postId: select(storeName).getEmailPostId(),
}),
[],
);
const [emailTitle = '', setTitle] = useEntityProp(
'postType',
'mailpoet_email',
'title',
);
const titleRef = useRef(null);
return (
<div ref={titleRef} className="mailpoet-email-editor-campaign-name">
<Dropdown
popoverProps={{
placement: 'bottom',
anchor: titleRef.current,
}}
contentClassName="mailpoet-email-editor-campaign-name__dropdown"
renderToggle={({ isOpen, onToggle }) => (
<>
<Button
onClick={onToggle}
className="mailpoet-email-campaign-name__link"
>
<Text size="body" as="h1">
<VisuallyHidden as="span">
{__('Editing email:', 'mailpoet')}
</VisuallyHidden>
{emailTitle}
</Text>
</Button>
<Button
className="mailpoet-email-campaign-name__toggle"
icon={chevronDown}
aria-expanded={isOpen}
aria-haspopup="true"
onClick={onToggle}
label={__('Change campaign name', 'mailpoet')}
>
{showIconLabels && __('Rename', 'mailpoet')}
</Button>
</>
)}
renderContent={() => (
<div className="mailpoet-email-editor-email-title-edit">
<TextControl
label={__('Campaign name', 'mailpoet')}
value={emailTitle}
onChange={(newTitle) => {
setTitle(newTitle);
}}
name="campaign_name"
help={__(
`Name your email campaign to indicate its purpose. This would only be visible to you and not shown to your subscribers.`,
'mailpoet',
)}
/>
</div>
)}
/>
</div>
);
}

View File

@ -0,0 +1,192 @@
import { useRef, useState } from '@wordpress/element';
import { PinnedItems } from '@wordpress/interface';
import { Button, ToolbarItem as WpToolbarItem } from '@wordpress/components';
import {
NavigableToolbar,
BlockToolbar as WPBlockToolbar,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';
// @ts-expect-error DocumentBar types are not available
import { DocumentBar, store as editorStore } from '@wordpress/editor';
import { store as preferencesStore } from '@wordpress/preferences';
import { __ } from '@wordpress/i18n';
import { plus, listView, undo, redo, next, previous } from '@wordpress/icons';
import classnames from 'classnames';
import { storeName } from '../../store';
import { MoreMenu } from './more-menu';
import { PreviewDropdown } from '../preview';
import { SaveButton } from './save-button';
import { CampaignName } from './campaign-name';
import { SendButton } from './send-button';
import { unlock } from '../../../lock-unlock';
// Build type for ToolbarItem contains only "as" and "children" properties but it takes all props from
// component passed to "as" property (in this case Button). So as fix for TS errors we need to pass all props from Button to ToolbarItem.
// We should be able to remove this fix when ToolbarItem will be fixed in Gutenberg.
const ToolbarItem = WpToolbarItem as React.ForwardRefExoticComponent<
React.ComponentProps<typeof WpToolbarItem> &
React.ComponentProps<typeof Button>
>;
// Definition of BlockToolbar in currently installed Gutenberg packages (wp-6.4) is missing hideDragHandle prop
// After updating to newer version of Gutenberg we should be able to remove this fix
const BlockToolbar = WPBlockToolbar as React.FC<
React.ComponentProps<typeof WPBlockToolbar> & {
hideDragHandle?: boolean;
}
>;
export function Header() {
const inserterButton = useRef();
const listviewButton = useRef();
const undoButton = useRef();
const redoButton = useRef();
const [isBlockToolsCollapsed, setIsBlockToolsCollapsed] = useState(false);
const { toggleInserterSidebar, toggleListviewSidebar } =
useDispatch(storeName);
const { undo: undoAction, redo: redoAction } = useDispatch(coreDataStore);
const {
isInserterSidebarOpened,
isListviewSidebarOpened,
isFixedToolbarActive,
isBlockSelected,
hasUndo,
hasRedo,
hasDocumentNavigationHistory,
} = useSelect((select) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { getEditorSettings: _getEditorSettings } = unlock(
select(editorStore),
);
const editorSettings = _getEditorSettings();
return {
isInserterSidebarOpened: select(storeName).isInserterSidebarOpened(),
isListviewSidebarOpened: select(storeName).isListviewSidebarOpened(),
isFixedToolbarActive: select(preferencesStore).get(
'core',
'fixedToolbar',
),
isBlockSelected: !!select(blockEditorStore).getBlockSelectionStart(),
hasUndo: select(coreDataStore).hasUndo(),
hasRedo: select(coreDataStore).hasRedo(),
hasDocumentNavigationHistory:
!!editorSettings.onNavigateToPreviousEntityRecord,
};
}, []);
const preventDefault = (event) => {
event.preventDefault();
};
const shortLabelInserter = !isInserterSidebarOpened
? __('Add', 'mailpoet')
: __('Close', 'mailpoet');
return (
<div className="editor-header edit-post-header">
<div className="editor-header__toolbar">
<NavigableToolbar
className="editor-document-tools edit-post-header-toolbar is-unstyled"
aria-label={__('Email document tools', 'mailpoet')}
>
<div className="editor-document-tools__left">
<ToolbarItem
ref={inserterButton}
as={Button}
className="editor-header-toolbar__inserter-toggle edit-post-header-toolbar__inserter-toggle"
variant="primary"
isPressed={isInserterSidebarOpened}
onMouseDown={preventDefault}
onClick={toggleInserterSidebar}
disabled={false}
icon={plus}
label={shortLabelInserter}
showTooltip
aria-expanded={isInserterSidebarOpened}
/>
<ToolbarItem
ref={undoButton}
as={Button}
className="editor-history__undo"
isPressed={false}
onMouseDown={preventDefault}
onClick={undoAction}
disabled={!hasUndo}
icon={undo}
label={__('Undo', 'mailpoet')}
showTooltip
/>
<ToolbarItem
ref={redoButton}
as={Button}
className="editor-history__redo"
isPressed={false}
onMouseDown={preventDefault}
onClick={redoAction}
disabled={!hasRedo}
icon={redo}
label={__('Redo', 'mailpoet')}
showTooltip
/>
<ToolbarItem
ref={listviewButton}
as={Button}
className="editor-header-toolbar__document-overview-toggle edit-post-header-toolbar__document-overview-toggle"
isPressed={isListviewSidebarOpened}
onMouseDown={preventDefault}
onClick={toggleListviewSidebar}
disabled={false}
icon={listView}
label={__('List view', 'mailpoet')}
showTooltip
aria-expanded={isInserterSidebarOpened}
/>
</div>
</NavigableToolbar>
{isFixedToolbarActive && isBlockSelected && (
<>
<div
className={classnames('editor-collapsible-block-toolbar', {
'is-collapsed': isBlockToolsCollapsed,
})}
>
<BlockToolbar hideDragHandle />
</div>
<Button
className="editor-header__block-tools-toggle edit-post-header__block-tools-toggle"
icon={isBlockToolsCollapsed ? next : previous}
onClick={() => {
setIsBlockToolsCollapsed((collapsed) => !collapsed);
}}
label={
isBlockToolsCollapsed
? __('Show block tools', 'mailpoet')
: __('Hide block tools', 'mailpoet')
}
/>
</>
)}
{(!isFixedToolbarActive ||
!isBlockSelected ||
isBlockToolsCollapsed) && (
<div className="editor-header__center edit-post-header__center">
{hasDocumentNavigationHistory ? <DocumentBar /> : <CampaignName />}
</div>
)}
</div>
<div className="editor-header__settings edit-post-header__settings">
<SaveButton />
<PreviewDropdown />
<SendButton />
<PinnedItems.Slot scope={storeName} />
<MoreMenu />
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
// Document actions - Component in header for displaying email/campaign title edit popup
.mailpoet-email-editor-campaign-name {
align-items: center;
display: flex;
flex-direction: row;
justify-content: center;
min-width: 0;
.components-dropdown {
display: inline-flex;
}
.components-button {
min-width: 0;
padding: 0;
}
.mailpoet-email-campaign-name__link {
display: inline-flex;
height: fit-content;
margin-right: 10px;
margin-top: 10px;
}
}
// Document actions - Popup for editing email/campaign title
.mailpoet-email-editor-campaign-name__dropdown {
.components-popover__content {
min-width: 280px;
padding: 0;
}
.mailpoet-email-editor-email-title-edit {
padding: 16px;
}
}
.mailpoet-email-editor-save-button__dropdown {
.components-popover__content {
min-width: 280px;
}
}

View File

@ -1 +1,3 @@
import './index.scss';
export * from './header'; export * from './header';

View File

@ -0,0 +1,113 @@
import { MenuGroup, MenuItem, DropdownMenu } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { displayShortcut } from '@wordpress/keycodes';
import { moreVertical } from '@wordpress/icons';
import { useEntityProp } from '@wordpress/core-data';
import { __, _x } from '@wordpress/i18n';
import { PreferenceToggleMenuItem } from '@wordpress/preferences';
import { useSelect, useDispatch } from '@wordpress/data';
import { storeName } from '../../store';
import { TrashModal } from './trash-modal';
// See:
// https://github.com/WordPress/gutenberg/blob/9601a33e30ba41bac98579c8d822af63dd961488/packages/edit-post/src/components/header/more-menu/index.js
// https://github.com/WordPress/gutenberg/blob/0ee78b1bbe9c6f3e6df99f3b967132fa12bef77d/packages/edit-site/src/components/header/more-menu/index.js
export function MoreMenu(): JSX.Element {
const [showTrashModal, setShowTrashModal] = useState(false);
const { urls, postId } = useSelect(
(select) => ({
urls: select(storeName).getUrls(),
postId: select(storeName).getEmailPostId(),
}),
[],
);
const [status, setStatus] = useEntityProp(
'postType',
'mailpoet_email',
'status',
);
const { saveEditedEmail, updateEmailMailPoetProperty } =
useDispatch(storeName);
const goToListings = () => {
window.location.href = urls.listings;
};
return (
<>
<DropdownMenu
className="edit-site-more-menu"
popoverProps={{
className: 'edit-site-more-menu__content',
}}
icon={moreVertical}
label={__('More', 'mailpoet')}
>
{() => (
<>
<MenuGroup label={_x('View', 'noun', 'mailpoet')}>
<PreferenceToggleMenuItem
scope="core"
name="fixedToolbar"
label={__('Top toolbar', 'mailpoet')}
info={__(
'Access all block and document tools in a single place',
'mailpoet',
)}
messageActivated={__('Top toolbar activated', 'mailpoet')}
messageDeactivated={__('Top toolbar deactivated', 'mailpoet')}
/>
<PreferenceToggleMenuItem
scope="core"
name="focusMode"
label={__('Spotlight mode', 'mailpoet')}
info={__('Focus at one block at a time', 'mailpoet')}
messageActivated={__('Spotlight mode activated', 'mailpoet')}
messageDeactivated={__(
'Spotlight mode deactivated',
'mailpoet',
)}
/>
<PreferenceToggleMenuItem
scope={storeName}
name="fullscreenMode"
label={__('Fullscreen mode', 'mailpoet')}
info={__('Work without distraction', 'mailpoet')}
messageActivated={__('Fullscreen mode activated', 'mailpoet')}
messageDeactivated={__(
'Fullscreen mode deactivated',
'mailpoet',
)}
shortcut={displayShortcut.secondary('f')}
/>
</MenuGroup>
<MenuGroup>
{status === 'trash' ? (
<MenuItem
onClick={async () => {
await setStatus('draft');
await updateEmailMailPoetProperty('deleted_at', '');
await saveEditedEmail();
}}
>
{__('Restore from trash', 'mailpoet')}
</MenuItem>
) : (
<MenuItem onClick={() => setShowTrashModal(true)} isDestructive>
{__('Move to trash', 'mailpoet')}
</MenuItem>
)}
</MenuGroup>
</>
)}
</DropdownMenu>
{showTrashModal && (
<TrashModal
onClose={() => setShowTrashModal(false)}
onRemove={goToListings}
postId={postId}
/>
)}
</>
);
}

View File

@ -0,0 +1,71 @@
import { useRef } from '@wordpress/element';
import { Button, Dropdown } from '@wordpress/components';
import {
// @ts-expect-error No types available for useEntitiesSavedStatesIsDirty
useEntitiesSavedStatesIsDirty,
// @ts-expect-error Our current version of packages doesn't have EntitiesSavedStates export
EntitiesSavedStates,
} from '@wordpress/editor';
import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { check, cloud, Icon } from '@wordpress/icons';
import { storeName } from '../../store';
export function SaveButton() {
const { saveEditedEmail } = useDispatch(storeName);
const { dirtyEntityRecords } = useEntitiesSavedStatesIsDirty();
const { hasEdits, isEmpty, isSaving } = useSelect(
(select) => ({
hasEdits: select(storeName).hasEdits(),
isEmpty: select(storeName).isEmpty(),
isSaving: select(storeName).isSaving(),
}),
[],
);
const buttonRef = useRef(null);
const hasNonEmailEdits = dirtyEntityRecords.some(
(entity) => entity.name !== 'mailpoet_email',
);
const isSaved = !isEmpty && !isSaving && !hasEdits;
const isDisabled = isEmpty || isSaving || isSaved;
let label = __('Save Draft', 'mailpoet');
if (isSaved) {
label = __('Saved', 'mailpoet');
} else if (isSaving) {
label = __('Saving', 'mailpoet');
}
return hasNonEmailEdits ? (
<div ref={buttonRef}>
<Dropdown
popoverProps={{
placement: 'bottom',
anchor: buttonRef.current,
}}
contentClassName="mailpoet-email-editor-save-button__dropdown"
renderToggle={({ onToggle }) => (
<Button onClick={onToggle} variant="tertiary">
{hasEdits
? __('Save email & template', 'mailpoet')
: __('Save template', 'mailpoet')}
</Button>
)}
renderContent={({ onToggle }) => (
<EntitiesSavedStates close={onToggle} />
)}
/>
</div>
) : (
<Button variant="tertiary" disabled={isDisabled} onClick={saveEditedEmail}>
{isSaving && <Icon icon={cloud} />}
{isSaved && <Icon icon={check} />}
{label}
</Button>
);
}

View File

@ -0,0 +1,50 @@
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import {
store as editorStore,
// @ts-expect-error No types available for useEntitiesSavedStatesIsDirty
useEntitiesSavedStatesIsDirty,
} from '@wordpress/editor';
import { useEntityProp } from '@wordpress/core-data';
import { MailPoetEmailData, storeName } from 'email-editor/engine/store';
import { useSelect } from '@wordpress/data';
import { useContentValidation } from 'email-editor/engine/hooks';
export function SendButton() {
const [mailpoetEmail] = useEntityProp(
'postType',
'mailpoet_email',
'mailpoet_data',
);
const { isDirty } = useEntitiesSavedStatesIsDirty();
const { validateContent, isValid } = useContentValidation();
const { hasEmptyContent, isEmailSent, isEditingTemplate } = useSelect(
(select) => ({
hasEmptyContent: select(storeName).hasEmptyContent(),
isEmailSent: select(storeName).isEmailSent(),
isEditingTemplate:
select(editorStore).getCurrentPostType() === 'wp_template',
}),
[],
);
const isDisabled =
isEditingTemplate || hasEmptyContent || isEmailSent || isValid || isDirty;
const mailpoetEmailData: MailPoetEmailData = mailpoetEmail;
return (
<Button
variant="primary"
onClick={() => {
if (validateContent()) {
window.location.href = `admin.php?page=mailpoet-newsletters#/send/${mailpoetEmailData.id}`;
}
}}
disabled={isDisabled}
>
{__('Send', 'mailpoet')}
</Button>
);
}

View File

@ -0,0 +1,78 @@
import { __ } from '@wordpress/i18n';
import { Button, Modal } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
export function TrashModal({
onClose,
onRemove,
postId,
}: {
onClose: () => void;
onRemove: () => void;
postId: number;
}) {
const { getLastEntityDeleteError } = useSelect(coreStore);
const { deleteEntityRecord } = useDispatch(coreStore);
const { createErrorNotice } = useDispatch(noticesStore);
const closeCallback = () => {
onClose();
};
const trashCallback = async () => {
const success = await deleteEntityRecord(
'postType',
'mailpoet_email',
postId as unknown as string,
{},
{ throwOnError: false },
);
if (success) {
onRemove();
} else {
const lastError = getLastEntityDeleteError(
'postType',
'mailpoet_email',
postId,
);
// Already deleted.
if (lastError?.code === 410) {
onRemove();
} else {
const errorMessage = lastError?.message
? (lastError.message as string)
: __(
'An error occurred while moving the email to the trash.',
'mailpoet',
);
await createErrorNotice(errorMessage, {
type: 'snackbar',
isDismissible: true,
context: 'email-editor',
});
}
}
};
return (
<Modal
className="mailpoet-move-to-trash-modal"
title={__('Move to trash', 'mailpoet')}
onRequestClose={closeCallback}
focusOnMount="firstContentElement"
>
<p>
{__('Are you sure you want to move this email to trash?', 'mailpoet')}
</p>
<div className="mailpoet-send-preview-modal-footer">
<Button variant="tertiary" onClick={closeCallback}>
{__('Cancel', 'mailpoet')}
</Button>
<Button variant="primary" onClick={trashCallback}>
{__('Move to trash', 'mailpoet')}
</Button>
</div>
</Modal>
);
}
export default TrashModal;

View File

@ -0,0 +1,35 @@
import {
__experimentalLibrary as Library,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { storeName } from '../../store';
export function InserterSidebar() {
const { postContentId, isEditingEmailContent } = useSelect((select) => {
const blocks = select(blockEditorStore).getBlocks();
return {
postContentId: blocks.find((block) => block.name === 'core/post-content')
?.clientId,
isEditingEmailContent:
select(editorStore).getCurrentPostType() !== 'wp_template',
};
});
const { toggleInserterSidebar } = useDispatch(storeName);
return (
<div className="editor-inserter-sidebar">
<div className="editor-inserter-sidebar__content">
<Library
showMostUsedBlocks
showInserterHelpPanel={false}
// In the email content mode we insert primarily into the post content block.
rootClientId={isEditingEmailContent ? postContentId : null}
onClose={toggleInserterSidebar}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,81 @@
import { useSelect, useDispatch } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import {
useShortcut,
store as keyboardShortcutsStore,
} from '@wordpress/keyboard-shortcuts';
import { __ } from '@wordpress/i18n';
import { storeName } from '../../store';
// See:
// https://github.com/WordPress/gutenberg/blob/9601a33e30ba41bac98579c8d822af63dd961488/packages/edit-post/src/components/keyboard-shortcuts/index.js
// https://github.com/WordPress/gutenberg/blob/0ee78b1bbe9c6f3e6df99f3b967132fa12bef77d/packages/edit-site/src/components/keyboard-shortcuts/index.js
export function KeyboardShortcuts(): null {
const { isSidebarOpened, hasEdits, isSaving } = useSelect((select) => ({
isSidebarOpened: select(storeName).isSidebarOpened(),
isSaving: select(storeName).isSaving(),
hasEdits: select(storeName).hasEdits(),
}));
const { openSidebar, closeSidebar, saveEditedEmail, toggleFeature } =
useDispatch(storeName);
const { registerShortcut } = useDispatch(keyboardShortcutsStore);
useEffect(() => {
void registerShortcut({
name: 'mailpoet/email-editor/toggle-fullscreen',
category: 'global',
description: __('Toggle fullscreen mode.', 'mailpoet'),
keyCombination: {
modifier: 'secondary',
character: 'f',
},
});
void registerShortcut({
name: 'mailpoet/email-editor/toggle-sidebar',
category: 'global',
description: __('Show or hide the settings sidebar.', 'mailpoet'),
keyCombination: {
modifier: 'primaryShift',
character: ',',
},
});
void registerShortcut({
name: 'mailpoet/email-editor/save',
category: 'global',
description: __('Save your changes.', 'mailpoet'),
keyCombination: {
modifier: 'primary',
character: 's',
},
});
}, [registerShortcut]);
useShortcut('mailpoet/email-editor/toggle-fullscreen', () => {
void toggleFeature('fullscreenMode');
});
useShortcut('mailpoet/email-editor/toggle-sidebar', (event) => {
event.preventDefault();
if (isSidebarOpened) {
void closeSidebar();
} else {
void openSidebar();
}
});
useShortcut('mailpoet/email-editor/save', (event) => {
event.preventDefault();
if (!hasEdits || isSaving) {
return;
}
void saveEditedEmail();
});
return null;
}

View File

@ -0,0 +1,9 @@
import { __experimentalListView as ListView } from '@wordpress/block-editor';
export function ListviewSidebar() {
return (
<div className="editor-list-view-sidebar">
<ListView />
</div>
);
}

View File

@ -0,0 +1,19 @@
.components-notice.mailpoet-email-editor-validation-errors {
margin: 0;
ul {
list-style: disc outside;
margin: 0 0 0 20px;
}
li {
margin: 8px 0;
padding: 0;
}
li:last-child {
margin-bottom: 0;
}
button.components-button.is-link {
color: #1e1e1e;
margin: 0 4px;
}
}

View File

@ -1,3 +1,5 @@
import './index.scss';
export { EditorNotices } from './notices'; export { EditorNotices } from './notices';
export { EditorSnackbars } from './snackbars'; export { EditorSnackbars } from './snackbars';
export { SentEmailNotice } from './sent-email-notice'; export { SentEmailNotice } from './sent-email-notice';

View File

@ -0,0 +1,43 @@
import { NoticeList } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
import { ValidationNotices } from './validation-notices';
import { EditorSnackbars } from './snackbars';
// See: https://github.com/WordPress/gutenberg/blob/5be0ec4153c3adf9f0f2513239f4f7a358ba7948/packages/editor/src/components/editor-notices/index.js
export function EditorNotices() {
const { notices } = useSelect(
(select) => ({
notices: select(noticesStore).getNotices('email-editor'),
}),
[],
);
const { removeNotice } = useDispatch(noticesStore);
const dismissibleNotices = notices.filter(
({ isDismissible, type }) => isDismissible && type === 'default',
);
const nonDismissibleNotices = notices.filter(
({ isDismissible, type }) => !isDismissible && type === 'default',
);
return (
<>
<NoticeList
notices={nonDismissibleNotices}
className="components-editor-notices__pinned"
/>
<NoticeList
notices={dismissibleNotices}
className="components-editor-notices__dismissible"
onRemove={(id) => removeNotice(id, 'email-editor')}
/>
<ValidationNotices />
<EditorSnackbars context="global" />
<EditorSnackbars context="email-editor" />
</>
);
}

View File

@ -0,0 +1,34 @@
import { dispatch, useSelect } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import { store as noticesStore } from '@wordpress/notices';
import { __ } from '@wordpress/i18n';
import { storeName } from '../../store';
export function SentEmailNotice() {
const { isEmailSent } = useSelect(
(select) => ({
isEmailSent: select(storeName).isEmailSent(),
}),
[],
);
useEffect(() => {
if (isEmailSent) {
void dispatch(noticesStore).createNotice(
'warning',
__(
'This email has already been sent. It can be edited, but not sent again. Duplicate this email if you want to send it again.',
'mailpoet',
),
{
id: 'email-sent',
isDismissible: false,
context: 'email-editor',
},
);
}
}, [isEmailSent]);
return null;
}

View File

@ -0,0 +1,26 @@
import { SnackbarList } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
// See: https://github.com/WordPress/gutenberg/blob/2788a9cf8b8149be3ee52dd15ce91fa55815f36a/packages/editor/src/components/editor-snackbars/index.js
export function EditorSnackbars({ context = 'email-editor' }) {
const { notices } = useSelect(
(select) => ({
notices: select(noticesStore).getNotices(context),
}),
[],
);
const { removeNotice } = useDispatch(noticesStore);
const snackbarNotices = notices.filter(({ type }) => type === 'snackbar');
return (
<SnackbarList
notices={snackbarNotices}
className="components-editor-notices__snackbar"
onRemove={(id) => removeNotice(id, context)}
/>
);
}

View File

@ -0,0 +1,37 @@
import { __ } from '@wordpress/i18n';
import { Notice, Button } from '@wordpress/components';
import { useValidationNotices } from 'email-editor/engine/hooks';
export function ValidationNotices() {
const { notices } = useValidationNotices();
if (notices.length === 0) {
return null;
}
return (
<Notice
status="error"
className="mailpoet-email-editor-validation-errors components-editor-notices__pinned"
isDismissible={false}
>
<>
<strong>{__('Fix errors to continue:', 'mailpoet')}</strong>
<ul>
{notices.map(({ id, content, actions }) => (
<li key={id}>
{content}
{actions.length > 0
? actions.map(({ label, onClick }) => (
<Button key={label} onClick={onClick} variant="link">
{label}
</Button>
))
: null}
</li>
))}
</ul>
</>
</Notice>
);
}

View File

@ -0,0 +1,28 @@
.components-modal__frame.mailpoet-send-preview-email {
max-width: 456px;
}
.mailpoet-send-preview-modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
}
.mailpoet-send-preview-modal-notice-error {
background-color: #fcf0f1;
gap: 12px;
padding: 12px 16px;
ul {
list-style: disc;
margin: 0;
padding: 5px 0 0 20px;
}
}
.mailpoet-send-preview-modal-notice-success {
align-items: center;
display: flex;
padding-top: 2px;
}

View File

@ -1,2 +1,4 @@
import './index.scss';
export * from './preview-dropdown'; export * from './preview-dropdown';
export * from './send-preview-email'; export * from './send-preview-email';

View File

@ -0,0 +1,100 @@
import {
MenuGroup,
MenuItem,
Button,
DropdownMenu,
} from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { Icon, external, check, mobile, desktop } from '@wordpress/icons';
import { SendPreviewEmail } from './send-preview-email';
import { storeName } from '../../store';
export function PreviewDropdown() {
const [mailpoetEmailData] = useEntityProp(
'postType',
'mailpoet_email',
'mailpoet_data',
);
const previewDeviceType = useSelect(
(select) => select(storeName).getDeviceType(),
[],
);
const { changePreviewDeviceType, togglePreviewModal } =
useDispatch(storeName);
const newsletterPreviewUrl: string = mailpoetEmailData?.preview_url || '';
const changeDeviceType = (newDeviceType: string) => {
void changePreviewDeviceType(newDeviceType);
};
const openInNewTab = (url: string) => {
window.open(url, '_blank', 'noreferrer');
};
const deviceIcons = {
mobile,
desktop,
};
return (
<>
<DropdownMenu
className="mailpoet-preview-dropdown"
label={__('Preview', 'mailpoet')}
icon={deviceIcons[previewDeviceType.toLowerCase()]}
>
{({ onClose }) => (
<>
<MenuGroup>
<MenuItem
className="block-editor-post-preview__button-resize"
onClick={() => changeDeviceType('Desktop')}
icon={previewDeviceType === 'Desktop' && check}
>
{__('Desktop', 'mailpoet')}
</MenuItem>
<MenuItem
className="block-editor-post-preview__button-resize"
onClick={() => changeDeviceType('Mobile')}
icon={previewDeviceType === 'Mobile' && check}
>
{__('Mobile', 'mailpoet')}
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem
className="block-editor-post-preview__button-resize"
onClick={() => {
void togglePreviewModal(true);
onClose();
}}
>
{__('Send a test email', 'mailpoet')}
</MenuItem>
</MenuGroup>
{newsletterPreviewUrl ? (
<MenuGroup>
<div className="edit-post-header-preview__grouping-external">
<Button
className="edit-post-header-preview__button-external components-menu-item__button"
onClick={() => {
openInNewTab(newsletterPreviewUrl);
}}
>
{__('Preview in new tab', 'mailpoet')}
<Icon icon={external} />
</Button>
</div>
</MenuGroup>
) : null}
</>
)}
</DropdownMenu>
<SendPreviewEmail />
</>
);
}

View File

@ -0,0 +1,177 @@
import { Button, Modal, TextControl } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { check, Icon } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
import {
useEffect,
useRef,
createInterpolateElement,
} from '@wordpress/element';
import { ENTER } from '@wordpress/keycodes';
import { isEmail } from '@wordpress/url';
import { useEntityProp } from '@wordpress/core-data';
import {
MailPoetEmailData,
SendingPreviewStatus,
storeName,
} from '../../store';
export function SendPreviewEmail() {
const sendToRef = useRef(null);
const {
requestSendingNewsletterPreview,
togglePreviewModal,
updateSendPreviewEmail,
} = useDispatch(storeName);
const {
toEmail: previewToEmail,
isSendingPreviewEmail,
sendingPreviewStatus,
isModalOpened,
} = useSelect((select) => select(storeName).getPreviewState(), []);
const [mailpoetEmailData] = useEntityProp(
'postType',
'mailpoet_email',
'mailpoet_data',
) as [MailPoetEmailData, unknown, unknown];
const handleSendPreviewEmail = () => {
void requestSendingNewsletterPreview(mailpoetEmailData.id, previewToEmail);
};
const closeCallback = () => togglePreviewModal(false);
// We use this effect to focus on the input field when the modal is opened
useEffect(() => {
if (isModalOpened) {
sendToRef.current?.focus();
}
}, [isModalOpened]);
if (!isModalOpened) {
return null;
}
return (
<Modal
className="mailpoet-send-preview-email"
title={__('Send a test email', 'mailpoet')}
onRequestClose={closeCallback}
focusOnMount={false}
>
{sendingPreviewStatus === SendingPreviewStatus.ERROR ? (
<div className="mailpoet-send-preview-modal-notice-error">
{__('Sorry, we were unable to send this email.', 'mailpoet')}
<ul>
<li>
{createInterpolateElement(
__(
'Please check your <link>sending method configuration</link> with your hosting provider.',
'mailpoet',
),
{
link: (
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
<a
href="admin.php?page=mailpoet-settings#mta"
target="_blank"
rel="noopener noreferrer"
/>
),
},
)}
</li>
<li>
{createInterpolateElement(
__(
'Or, sign up for MailPoet Sending Service to easily send emails. <link>Sign up for free</link>',
'mailpoet',
),
{
link: (
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
<a
href={new URL(
'free-plan',
'https://www.mailpoet.com/',
).toString()}
key="sign-up-for-free"
target="_blank"
rel="noopener noreferrer"
/>
),
},
)}
</li>
</ul>
</div>
) : null}
<p>
{createInterpolateElement(
__(
'Send yourself a test email to test how your email would look like in different email apps. You can also test your spam score by sending a test email to <link1>{$serviceName}</link1>. <link2>Learn more</link2>.',
'mailpoet',
).replace('{$serviceName}', 'Mail Tester'),
{
link1: (
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
<a
href="https://www.mail-tester.com/"
target="_blank"
rel="noopener noreferrer"
/>
),
link2: (
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
<a
href="https://kb.mailpoet.com/article/147-test-your-spam-score-with-mail-tester"
target="_blank"
rel="noopener noreferrer"
/>
),
},
)}
</p>
<TextControl
label={__('Send to', 'mailpoet')}
onChange={(email) => {
void updateSendPreviewEmail(email);
}}
onKeyDown={(event) => {
const { keyCode } = event;
if (keyCode === ENTER) {
event.preventDefault();
handleSendPreviewEmail();
}
}}
value={previewToEmail}
type="email"
ref={sendToRef}
required
/>
{sendingPreviewStatus === SendingPreviewStatus.SUCCESS ? (
<p className="mailpoet-send-preview-modal-notice-success">
<Icon icon={check} style={{ fill: '#4AB866' }} />
{__('Test email sent successfully!', 'mailpoet')}
</p>
) : null}
<div className="mailpoet-send-preview-modal-footer">
<Button variant="tertiary" onClick={closeCallback}>
{__('Close', 'mailpoet')}
</Button>
<Button
variant="primary"
onClick={handleSendPreviewEmail}
disabled={isSendingPreviewEmail || !isEmail(previewToEmail)}
>
{isSendingPreviewEmail
? __('Sending...', 'mailpoet')
: __('Send test email', 'mailpoet')}
</Button>
</div>
</Modal>
);
}

View File

@ -0,0 +1,63 @@
import { hasBlockSupport, getBlockSupport } from '@wordpress/blocks';
import { Fill, Notice } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
export const hasBackgroundImageSupport = (nameOrType: string) => {
// @ts-expect-error not yet supported in the types
const backgroundSupport = getBlockSupport(nameOrType, 'background') as Record<
string,
boolean
>;
return backgroundSupport && backgroundSupport?.backgroundImage !== false;
};
export function BlockCompatibilityWarnings(): JSX.Element {
// Select the currently selected block
const selectedBlock = useSelect(
(sel) => sel('core/block-editor').getSelectedBlock(),
[],
);
// Check if the selected block has enabled border configuration
const hasBorderSupport = hasBlockSupport(
selectedBlock?.name,
// @ts-expect-error Border is not yet supported in the types
'__experimentalBorder',
false,
);
return (
<>
{hasBorderSupport && (
<Fill name="InspectorControlsBorder">
<Notice
className="mailpoet__grid-full-width"
status="warning"
isDismissible={false}
>
{__(
'Border display may vary or be unsupported in some email clients.',
'mailpoet',
)}
</Notice>
</Fill>
)}
{hasBackgroundImageSupport(selectedBlock?.name) && (
<Fill name="InspectorControlsBackground">
<Notice
className="mailpoet__grid-full-width"
status="warning"
isDismissible={false}
>
{__(
'Select a background color for email clients that do not support background images.',
'mailpoet',
)}
</Notice>
</Fill>
)}
</>
);
}

View File

@ -0,0 +1,131 @@
import {
__experimentalText as Text,
ExternalLink,
PanelBody,
TextareaControl,
} from '@wordpress/components';
import { useDispatch } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import classnames from 'classnames';
import { storeName } from '../../store';
const previewTextMaxLength = 150;
const previewTextRecommendedLength = 80;
export function DetailsPanel() {
const [mailpoetEmailData] = useEntityProp(
'postType',
'mailpoet_email',
'mailpoet_data',
);
const { updateEmailMailPoetProperty } = useDispatch(storeName);
const subjectHelp = createInterpolateElement(
__(
'Use shortcodes to personalize your email, or learn more about <bestPracticeLink>best practices</bestPracticeLink> and using <emojiLink>emoji in subject lines</emojiLink>.',
'mailpoet',
),
{
bestPracticeLink: (
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
<a
href="https://www.mailpoet.com/blog/17-email-subject-line-best-practices-to-boost-engagement/"
target="_blank"
rel="noopener noreferrer"
/>
),
emojiLink: (
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
<a
href="https://www.mailpoet.com/blog/tips-using-emojis-in-subject-lines/"
target="_blank"
rel="noopener noreferrer"
/>
),
},
);
const subjectLabel = (
<>
<span>{__('Subject', 'mailpoet')}</span>
<ExternalLink href="https://kb.mailpoet.com/article/215-personalize-newsletter-with-shortcodes#list">
{__('Shortcode guide', 'mailpoet')}
</ExternalLink>
</>
);
const previewTextLength = mailpoetEmailData?.preheader?.length ?? 0;
const preheaderLabel = (
<>
<span>{__('Preview text', 'mailpoet')}</span>
<span
className={classnames('mailpoet-settings-panel__preview-text-length', {
'mailpoet-settings-panel__preview-text-length-warning':
previewTextLength > previewTextRecommendedLength,
'mailpoet-settings-panel__preview-text-length-error':
previewTextLength > previewTextMaxLength,
})}
>
{previewTextLength}/{previewTextMaxLength}
</span>
</>
);
return (
<PanelBody
title={__('Details', 'mailpoet')}
className="mailpoet-email-editor__settings-panel"
>
<TextareaControl
className="mailpoet-settings-panel__subject"
label={subjectLabel}
placeholder={__('Eg. The summer sale is here!', 'mailpoet')}
value={mailpoetEmailData?.subject ?? ''}
onChange={(value) => updateEmailMailPoetProperty('subject', value)}
data-automation-id="email_subject"
/>
<div className="mailpoet-settings-panel__help">
<Text>{subjectHelp}</Text>
</div>
<TextareaControl
className="mailpoet-settings-panel__preview-text"
label={preheaderLabel}
placeholder={__(
"Add a preview text to capture subscribers' attention and increase open rates.",
'mailpoet',
)}
value={mailpoetEmailData?.preheader ?? ''}
onChange={(value) => updateEmailMailPoetProperty('preheader', value)}
data-automation-id="email_preview_text"
/>
<div className="mailpoet-settings-panel__help">
<Text>
{createInterpolateElement(
__(
'<link>This text</link> will appear in the inbox, underneath the subject line.',
'mailpoet',
),
{
link: (
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
<a
href={new URL(
'article/418-preview-text',
'https://kb.mailpoet.com/',
).toString()}
key="preview-text-kb"
target="_blank"
rel="noopener noreferrer"
/>
),
},
)}
</Text>
</div>
</PanelBody>
);
}

View File

@ -1,19 +1,14 @@
/**
* External dependencies
*/
import { Panel } from '@wordpress/components'; import { Panel } from '@wordpress/components';
/**
* Internal dependencies
*/
import { DetailsPanel } from './details-panel'; import { DetailsPanel } from './details-panel';
import { EmailTypeInfo } from './email-type-info'; import { EmailTypeInfo } from './email-type-info';
import { TemplatesPanel } from './templates-panel';
export function EmailSettings() { export function EmailSettings() {
return ( return (
<Panel> <Panel>
<EmailTypeInfo /> <EmailTypeInfo />
<DetailsPanel /> <TemplatesPanel />
</Panel> <DetailsPanel />
); </Panel>
);
} }

View File

@ -0,0 +1,26 @@
import { Panel, PanelBody, PanelRow } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Icon, megaphone } from '@wordpress/icons';
export function EmailTypeInfo() {
return (
<Panel className="mailpoet-email-sidebar__email-type-info">
<PanelBody>
<PanelRow>
<span className="mailpoet-email-type-info__icon">
<Icon icon={megaphone} />
</span>
<div className="mailpoet-email-type-info__content">
<h2>{__('Newsletter', 'mailpoet')}</h2>
<span>
{__(
'Send or schedule a newsletter to connect with your subscribers.',
'mailpoet',
)}
</span>
</div>
</PanelRow>
</PanelBody>
</Panel>
);
}

View File

@ -0,0 +1,18 @@
import { __ } from '@wordpress/i18n';
import * as React from '@wordpress/element';
import { privateApis as componentsPrivateApis } from '@wordpress/components';
import { mainSidebarEmailTab, mainSidebarBlockTab } from '../../store';
import { unlock } from '../../../lock-unlock';
const { Tabs } = unlock(componentsPrivateApis);
export function HeaderTabs(_, ref) {
return (
<Tabs.TabList ref={ref}>
<Tabs.Tab tabId={mainSidebarEmailTab}>{__('Email', 'mailpoet')}</Tabs.Tab>
<Tabs.Tab tabId={mainSidebarBlockTab}>{__('Block')}</Tabs.Tab>
</Tabs.TabList>
);
}
export const Header = React.forwardRef(HeaderTabs);

View File

@ -0,0 +1,17 @@
@import '~@wordpress/base-styles/colors';
.background-block-support-panel .mailpoet__grid-full-width,
.border-block-support-panel .mailpoet__grid-full-width {
// setting the grid to 100% width because border block contains two grid columns
grid-column: 1/3;
// resetting margin fits the notice to the full column width
margin: 0;
}
// Disable focus outline for buttons in the sidebar
.edit-post-sidebar {
.components-button:focus:not(:disabled) {
box-shadow: none;
outline: none;
}
}

View File

@ -1,2 +1,4 @@
import './index.scss';
export * from './block-compatibility-warnings'; export * from './block-compatibility-warnings';
export * from './sidebar'; export * from './sidebar';

View File

@ -0,0 +1,97 @@
import { __ } from '@wordpress/i18n';
import { useContext, useRef, useEffect } from '@wordpress/element';
import { privateApis as componentsPrivateApis } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import {
BlockInspector,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { ComplementaryArea } from '@wordpress/interface';
import { drawerRight } from '@wordpress/icons';
import { store as editorStore } from '@wordpress/editor';
import {
storeName,
mainSidebarEmailTab,
mainSidebarBlockTab,
mainSidebarId,
} from '../../store';
import { Header } from './header';
import { EmailSettings } from './email-settings';
import { TemplateSettings } from './template-settings';
import { unlock } from '../../../lock-unlock';
import './index.scss';
const { Tabs } = unlock(componentsPrivateApis);
type Props = React.ComponentProps<typeof ComplementaryArea>;
function SidebarContent(props: Props) {
const { isEditingTemplate } = useSelect(
(select) => ({
isEditingTemplate:
select(editorStore).getCurrentPostType() === 'wp_template',
}),
[],
);
const tabListRef = useRef(null);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const tabsContextValue = useContext(Tabs.Context);
return (
<ComplementaryArea
identifier={mainSidebarId}
headerClassName="editor-sidebar__panel-tabs"
className="edit-post-sidebar"
header={
<Tabs.Context.Provider value={tabsContextValue}>
<Header ref={tabListRef} />
</Tabs.Context.Provider>
}
icon={drawerRight}
scope={storeName}
smallScreenTitle={__('No title', 'mailpoet')}
isActiveByDefault
{...props}
>
<Tabs.Context.Provider value={tabsContextValue}>
<Tabs.TabPanel tabId={mainSidebarEmailTab}>
{isEditingTemplate ? <TemplateSettings /> : <EmailSettings />}
</Tabs.TabPanel>
<Tabs.TabPanel tabId={mainSidebarBlockTab}>
<BlockInspector />
</Tabs.TabPanel>
</Tabs.Context.Provider>
</ComplementaryArea>
);
}
export function Sidebar(props: Props) {
const { toggleSettingsSidebarActiveTab } = useDispatch(storeName);
const { activeTab, selectedBlockId } = useSelect(
(select) => ({
activeTab: select(storeName).getSettingsSidebarActiveTab(),
selectedBlockId: select(blockEditorStore).getSelectedBlockClientId(),
}),
[],
);
// Switch tab based on selected block.
useEffect(() => {
if (selectedBlockId) {
void toggleSettingsSidebarActiveTab(mainSidebarBlockTab);
} else {
void toggleSettingsSidebarActiveTab(mainSidebarEmailTab);
}
}, [selectedBlockId, toggleSettingsSidebarActiveTab]);
return (
<Tabs
selectedTabId={activeTab || mainSidebarEmailTab}
onSelect={(key) => toggleSettingsSidebarActiveTab(key as string)}
>
<SidebarContent {...props} />
</Tabs>
);
}

View File

@ -0,0 +1,30 @@
import { Panel, PanelBody, PanelRow } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { Icon, layout } from '@wordpress/icons';
import { storeName } from '../../store';
export function TemplateInfo() {
const template = useSelect(
(select) => select(storeName).getCurrentTemplate(),
[],
);
return (
<Panel className="mailpoet-email-sidebar__email-type-info">
<PanelBody>
<PanelRow>
<span className="mailpoet-email-type-info__icon">
<Icon icon={layout} />
</span>
<div className="mailpoet-email-type-info__content">
{/* @ts-expect-error Todo template type is not defined */}
<h2>{template?.title || __('Template', 'mailpoet')}</h2>
{/* @ts-expect-error Todo template type is not defined */}
<span>{template?.description || ''}</span>
</div>
</PanelRow>
</PanelBody>
</Panel>
);
}

View File

@ -0,0 +1,12 @@
import { Panel } from '@wordpress/components';
import { TemplateInfo } from './template-info';
import { TemplatesPanel } from './templates-panel';
export function TemplateSettings() {
return (
<Panel>
<TemplateInfo />
<TemplatesPanel />
</Panel>
);
}

View File

@ -0,0 +1,132 @@
import { PanelBody, Button } from '@wordpress/components';
import { useSelect, useDispatch, dispatch, select } from '@wordpress/data';
import { __, sprintf } from '@wordpress/i18n';
import { store as editorStore } from '@wordpress/editor';
import { decodeEntities } from '@wordpress/html-entities';
import { store as coreStore } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
import apiFetch from '@wordpress/api-fetch';
import {
parse,
// @ts-expect-error No types available for this yet.
__unstableSerializeAndClean,
BlockInstance,
} from '@wordpress/blocks';
import { addQueryArgs } from '@wordpress/url';
import { storeName } from '../../store';
import { unlock } from '../../../lock-unlock';
// Todo: This is not available yet. Replace when possible.
async function revertTemplate(template) {
const templateEntityConfig = select(coreStore).getEntityConfig(
'postType',
template.type as string,
);
const fileTemplatePath = addQueryArgs(
`${templateEntityConfig.baseURL as string}/${template.id as string}`,
{ context: 'edit', source: 'theme' },
);
const fileTemplate = await apiFetch({ path: fileTemplatePath });
const serializeBlocks = ({ blocks: blocksForSerialization = [] }) =>
__unstableSerializeAndClean(blocksForSerialization) as BlockInstance[];
// @ts-expect-error template type is not defined
const blocks = parse(fileTemplate?.content?.raw as string);
void dispatch(coreStore).editEntityRecord(
'postType',
template.type as string,
// @ts-expect-error template type is not defined
fileTemplate.id as string,
{
content: serializeBlocks,
blocks,
source: 'theme',
},
);
}
export function TemplatesPanel() {
const { onNavigateToEntityRecord, template, hasHistory } = useSelect(
(sel) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { getEditorSettings: _getEditorSettings } = unlock(
sel(editorStore),
);
const editorSettings = _getEditorSettings();
return {
onNavigateToEntityRecord: editorSettings.onNavigateToEntityRecord,
hasHistory: !!editorSettings.onNavigateToPreviousEntityRecord,
template: sel(storeName).getEditedPostTemplate(),
};
},
[],
);
const { saveEditedEntityRecord } = useDispatch(coreStore);
const { createSuccessNotice, createErrorNotice } = useDispatch(noticesStore);
async function revertAndSaveTemplate() {
try {
await revertTemplate(template);
await saveEditedEntityRecord('postType', template.type, template.id, {});
void createSuccessNotice(
sprintf(
/* translators: The template/part's name. */
__('"%s" reset.', 'mailpoet'),
decodeEntities(template.title),
),
{
type: 'snackbar',
id: 'edit-site-template-reverted',
},
);
} catch (error) {
void createErrorNotice(
__('An error occurred while reverting the template.', 'mailpoet'),
{
type: 'snackbar',
},
);
}
}
return (
<PanelBody
title={__('Templates Experiment', 'mailpoet')}
className="mailpoet-email-editor__settings-panel"
>
<p>
Components from this Panel will be placed in different areas of the UI.
They are place here in one place just to simplify the experiment.
</p>
<hr />
<h3>Edit template toggle</h3>
{template && !hasHistory && (
<Button
variant="primary"
onClick={() => {
onNavigateToEntityRecord({
postId: template.id,
postType: 'wp_template',
});
}}
disabled={!template.id}
>
{__('Edit template', 'mailpoet')}
</Button>
)}
<hr />
<h3>Revert Template</h3>
<Button
variant="primary"
onClick={() => {
void revertAndSaveTemplate();
}}
>
{__('Revert customizations', 'mailpoet')}
</Button>
</PanelBody>
);
}

View File

@ -0,0 +1,56 @@
.mailpoet-email-editor__styles-panel {
display: flex;
flex-direction: column;
height: 100%;
.components-panel,
.components-navigator-provider,
.components-navigator-screen {
height: 100%;
overflow: hidden;
}
.components-navigator-screen {
overflow-x: hidden;
overflow-y: auto;
}
.components-navigator-button {
width: 100%;
}
.mailpoet-email-editor__styles-header {
margin-bottom: 0;
}
.mailpoet-email-editor__styles-header-description {
padding: 0 16px;
}
h3.edit-site-global-styles-subtitle {
margin-bottom: 0;
}
.single-column {
grid-column: span 1;
}
.edit-site-global-styles-screen-typography__indicator {
border-radius: 3px;
height: 2em;
line-height: 2em;
padding: 0;
width: 2em;
}
.edit-site-typography-preview {
align-items: center;
background: #f0f0f0;
border-radius: 2px;
display: flex;
justify-content: center;
margin-bottom: 16px;
min-height: 100px;
overflow: hidden;
}
}

View File

@ -1 +1,3 @@
import './index.scss';
export * from './styles-sidebar'; export * from './styles-sidebar';

View File

@ -0,0 +1,80 @@
import {
// We can remove the ts-expect-error comments once the types are available.
// @ts-expect-error TS7016: Could not find a declaration file for module '@wordpress/block-editor'.
__experimentalSpacingSizesControl as SpacingSizesControl,
useSettings,
} from '@wordpress/block-editor';
import {
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
__experimentalUseCustomUnits as useCustomUnits,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { isEqual } from 'lodash';
import { useEmailStyles } from '../../../hooks';
export function DimensionsPanel() {
const [availableUnits] = useSettings('spacing.units') as [string[]];
const units = useCustomUnits({
availableUnits,
});
const { styles, defaultStyles, updateStyleProp } = useEmailStyles();
return (
<ToolsPanel
label={__('Dimensions', 'mailpoet')}
resetAll={() => {
updateStyleProp(['spacing'], defaultStyles.spacing);
}}
>
<ToolsPanelItem
isShownByDefault
hasValue={() =>
!isEqual(styles.spacing.padding, defaultStyles.spacing.padding)
}
label={__('Padding', 'mailpoet')}
onDeselect={() =>
updateStyleProp(['spacing', 'padding'], defaultStyles.spacing.padding)
}
className="tools-panel-item-spacing"
>
<SpacingSizesControl
allowReset
values={styles.spacing.padding}
onChange={(value) => {
updateStyleProp(['spacing', 'padding'], value);
}}
label={__('Padding', 'mailpoet')}
sides={['horizontal', 'vertical', 'top', 'left', 'right', 'bottom']}
units={units}
/>
</ToolsPanelItem>
<ToolsPanelItem
isShownByDefault
label={__('Block spacing', 'mailpoet')}
hasValue={() =>
styles.spacing.blockGap !== defaultStyles.spacing.blockGap
}
onDeselect={() =>
updateStyleProp(
['spacing', 'blockGap'],
defaultStyles.spacing.blockGap,
)
}
className="tools-panel-item-spacing"
>
<SpacingSizesControl
label={__('Block spacing', 'mailpoet')}
min={0}
onChange={(value) => {
updateStyleProp(['spacing', 'blockGap'], value.top);
}}
showSideInLabel={false}
sides={['top']} // Use 'top' as the shorthand property in non-axial configurations.
values={{ top: styles.spacing.blockGap }}
allowReset
/>
</ToolsPanelItem>
</ToolsPanel>
);
}

View File

@ -0,0 +1,265 @@
import { __ } from '@wordpress/i18n';
import { useCallback } from '@wordpress/element';
import {
useSettings,
// We can remove the ts-expect-error comments once the types are available.
// @see packages/block-editor/src/components/index.js
// @ts-expect-error TS7016: Could not find a declaration file for module '@wordpress/block-editor'.
__experimentalFontAppearanceControl as FontAppearanceControl,
// @ts-expect-error TS7016: Could not find a declaration file for module '@wordpress/block-editor'.
__experimentalLetterSpacingControl as LetterSpacingControl,
// @ts-expect-error TS7016: Could not find a declaration file for module '@wordpress/block-editor'.
__experimentalFontFamilyControl as FontFamilyControl,
// @ts-expect-error TS7016: Could not find a declaration file for module '@wordpress/block-editor'.
LineHeightControl,
// @ts-expect-error TS7016: Could not find a declaration file for module '@wordpress/block-editor'.
__experimentalTextDecorationControl as TextDecorationControl,
// @ts-expect-error TS7016: Could not find a declaration file for module '@wordpress/block-editor'.
__experimentalTextTransformControl as TextTransformControl,
} from '@wordpress/block-editor';
import {
FontSizePicker,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import { useEmailStyles } from '../../../hooks';
import { getElementStyles } from '../utils';
export const DEFAULT_CONTROLS = {
fontFamily: true,
fontSize: true,
fontAppearance: true,
lineHeight: true,
letterSpacing: false,
textTransform: false,
textDecoration: false,
writingMode: true,
textColumns: true,
};
export function TypographyElementPanel({
element,
headingLevel,
defaultControls = DEFAULT_CONTROLS,
}: {
element: string;
headingLevel: string;
defaultControls?: typeof DEFAULT_CONTROLS;
}) {
const [fontSizes, blockLevelFontFamilies] = useSettings(
'typography.fontSizes',
'typography.fontFamilies',
);
// Ref: https://github.com/WordPress/gutenberg/issues/59778
const fontFamilies = blockLevelFontFamilies?.default || [];
const { styles, defaultStyles, updateStyleProp } = useEmailStyles();
const elementStyles = getElementStyles(styles, element, headingLevel);
const defaultElementStyles = getElementStyles(
defaultStyles,
element,
headingLevel,
);
const {
fontFamily,
fontSize,
fontStyle,
fontWeight,
lineHeight,
letterSpacing,
textDecoration,
textTransform,
} = elementStyles.typography;
const {
fontFamily: defaultFontFamily,
fontSize: defaultFontSize,
fontStyle: defaultFontStyle,
fontWeight: defaultFontWeight,
lineHeight: defaultLineHeight,
letterSpacing: defaultLetterSpacing,
textDecoration: defaultTextDecoration,
textTransform: defaultTextTransform,
} = defaultElementStyles.typography;
const hasFontFamily = () => fontFamily !== defaultFontFamily;
const hasFontSize = () => fontSize !== defaultFontSize;
const hasFontAppearance = () =>
fontWeight !== defaultFontWeight || fontStyle !== defaultFontStyle;
const hasLineHeight = () => lineHeight !== defaultLineHeight;
const hasLetterSpacing = () => letterSpacing !== defaultLetterSpacing;
const hasTextDecoration = () => textDecoration !== defaultTextDecoration;
const hasTextTransform = () => textTransform !== defaultTextTransform;
const showToolFontSize = element !== 'heading' || headingLevel !== 'heading';
const updateElementStyleProp = useCallback(
(path, newValue) => {
if (element === 'heading') {
updateStyleProp(['elements', headingLevel, ...path], newValue);
} else if (element === 'text') {
updateStyleProp([...path], newValue);
} else {
updateStyleProp(['elements', element, ...path], newValue);
}
},
[element, updateStyleProp, headingLevel],
);
const setLetterSpacing = (newValue) => {
updateElementStyleProp(['typography', 'letterSpacing'], newValue);
};
const setLineHeight = (newValue) => {
updateElementStyleProp(['typography', 'lineHeight'], newValue);
};
const setFontSize = (newValue) => {
updateElementStyleProp(['typography', 'fontSize'], newValue);
};
const setFontFamily = (newValue) => {
updateElementStyleProp(['typography', 'fontFamily'], newValue);
};
const setTextDecoration = (newValue) => {
updateElementStyleProp(['typography', 'textDecoration'], newValue);
};
const setTextTransform = (newValue) => {
updateElementStyleProp(['typography', 'textTransform'], newValue);
};
const setFontAppearance = ({
fontStyle: newFontStyle,
fontWeight: newFontWeight,
}) => {
updateElementStyleProp(['typography', 'fontStyle'], newFontStyle);
updateElementStyleProp(['typography', 'fontWeight'], newFontWeight);
};
const resetAll = () => {
updateElementStyleProp(['typography'], defaultElementStyles.typography);
};
return (
<ToolsPanel label={__('Typography', 'mailpoet')} resetAll={resetAll}>
<ToolsPanelItem
label={__('Font family', 'mailpoet')}
hasValue={hasFontFamily}
onDeselect={() => setFontFamily(defaultFontFamily)}
isShownByDefault={defaultControls.fontFamily}
>
<FontFamilyControl
value={fontFamily}
onChange={setFontFamily}
size="__unstable-large"
fontFamilies={fontFamilies}
__nextHasNoMarginBottom
/>
</ToolsPanelItem>
{showToolFontSize && (
<ToolsPanelItem
label={__('Font size', 'mailpoet')}
hasValue={hasFontSize}
onDeselect={() => setFontSize(defaultFontSize)}
isShownByDefault={defaultControls.fontSize}
>
<FontSizePicker
value={fontSize}
onChange={setFontSize}
fontSizes={fontSizes}
disableCustomFontSizes={false}
withReset={false}
withSlider
size="__unstable-large"
__nextHasNoMarginBottom
/>
</ToolsPanelItem>
)}
<ToolsPanelItem
className="single-column"
label={__('Appearance', 'mailpoet')}
hasValue={hasFontAppearance}
onDeselect={() => {
setFontAppearance({
fontStyle: defaultFontStyle,
fontWeight: defaultFontWeight,
});
}}
isShownByDefault={defaultControls.fontAppearance}
>
<FontAppearanceControl
value={{
fontStyle,
fontWeight,
}}
onChange={setFontAppearance}
hasFontStyles
hasFontWeights
size="__unstable-large"
__nextHasNoMarginBottom
/>
</ToolsPanelItem>
<ToolsPanelItem
className="single-column"
label={__('Line height', 'mailpoet')}
hasValue={hasLineHeight}
onDeselect={() => setLineHeight(defaultLineHeight)}
isShownByDefault={defaultControls.lineHeight}
>
<LineHeightControl
__nextHasNoMarginBottom
__unstableInputWidth="auto"
value={lineHeight}
onChange={setLineHeight}
size="__unstable-large"
/>
</ToolsPanelItem>
<ToolsPanelItem
className="single-column"
label={__('Letter spacing', 'mailpoet')}
hasValue={hasLetterSpacing}
onDeselect={() => setLetterSpacing(defaultLetterSpacing)}
isShownByDefault={defaultControls.letterSpacing}
>
<LetterSpacingControl
value={letterSpacing}
onChange={setLetterSpacing}
size="__unstable-large"
__unstableInputWidth="auto"
/>
</ToolsPanelItem>
<ToolsPanelItem
className="single-column"
label={__('Text decoration', 'mailpoet')}
hasValue={hasTextDecoration}
onDeselect={() => setTextDecoration(defaultTextDecoration)}
isShownByDefault={defaultControls.textDecoration}
>
<TextDecorationControl
value={textDecoration}
onChange={setTextDecoration}
size="__unstable-large"
__unstableInputWidth="auto"
/>
</ToolsPanelItem>
<ToolsPanelItem
label={__('Letter case', 'mailpoet')}
hasValue={hasTextTransform}
onDeselect={() => setTextTransform(defaultTextTransform)}
isShownByDefault={defaultControls.textTransform}
>
<TextTransformControl
value={textTransform}
onChange={setTextTransform}
showNone
isBlock
size="__unstable-large"
__nextHasNoMarginBottom
/>
</ToolsPanelItem>
</ToolsPanel>
);
}
export default TypographyElementPanel;

View File

@ -0,0 +1,90 @@
/**
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import {
__experimentalItemGroup as ItemGroup,
__experimentalItem as Item,
__experimentalVStack as VStack,
__experimentalHStack as HStack,
__experimentalHeading as Heading,
__experimentalNavigatorButton as NavigatorButton,
FlexItem,
Card,
CardBody,
} from '@wordpress/components';
import { useEmailStyles } from '../../../hooks';
import { getElementStyles } from '../utils';
function ElementItem({ element, label }: { element: string; label: string }) {
const { styles } = useEmailStyles();
const elementStyles = getElementStyles(styles, element, null, true);
const {
fontFamily,
fontStyle,
fontWeight,
letterSpacing,
textDecoration,
textTransform,
} = elementStyles.typography;
const textColor = elementStyles.color?.text || 'inherit';
const background = elementStyles.color?.background || '#f0f0f0';
const navigationButtonLabel = sprintf(
// translators: %s: is a subset of Typography, e.g., 'text' or 'links'.
__('Typography %s styles', 'mailpoet'),
label,
);
return (
<Item>
<NavigatorButton
path={`/typography/${element}`}
aria-label={navigationButtonLabel}
>
<HStack justify="flex-start">
<FlexItem
className="edit-site-global-styles-screen-typography__indicator"
style={{
fontFamily: fontFamily ?? 'serif',
background,
color: textColor,
fontStyle: fontStyle ?? 'normal',
fontWeight: fontWeight ?? 'normal',
letterSpacing: letterSpacing ?? 'normal',
textDecoration:
textDecoration ?? (element === 'link' ? 'underline' : 'none'),
textTransform: textTransform ?? 'none',
}}
>
Aa
</FlexItem>
<FlexItem>{label}</FlexItem>
</HStack>
</NavigatorButton>
</Item>
);
}
export function TypographyPanel() {
return (
<Card size="small" variant="primary" isBorderless>
<CardBody>
<VStack spacing={3}>
<Heading level={3} className="edit-site-global-styles-subtitle">
{__('Elements', 'mailpoet')}
</Heading>
<ItemGroup isBordered isSeparated size="small">
<ElementItem element="text" label={__('Text', 'mailpoet')} />
<ElementItem element="link" label={__('Links', 'mailpoet')} />
<ElementItem element="heading" label={__('Headings', 'mailpoet')} />
<ElementItem element="button" label={__('Buttons', 'mailpoet')} />
</ItemGroup>
</VStack>
</CardBody>
</Card>
);
}
export default TypographyPanel;

Some files were not shown because too many files have changed in this diff Show More