first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
langcode: en
status: true
dependencies:
config:
- filter.format.basic_html
# TRICKY: This technically is a module that this config depends on, but it has been removed from Drupal >=10.
# module:
# - ckeditor
format: basic_html
editor: ckeditor
settings:
toolbar:
rows:
-
-
name: Formatting
items:
- Bold
- Italic
-
name: Linking
items:
- DrupalLink
- DrupalUnlink
-
name: Lists
items:
- BulletedList
- NumberedList
-
name: Media
items:
- Blockquote
- DrupalImage
-
name: 'Block Formatting'
items:
- Format
-
name: Tools
items:
- Source
plugins: {}
image_upload:
status: true
scheme: public
directory: inline-images
max_size: ''
max_dimensions:
width: 0
height: 0

View File

@@ -0,0 +1,59 @@
langcode: en
status: true
dependencies:
config:
- filter.format.full_html
# TRICKY: This technically is a module that this config depends on, but it has been removed from Drupal >=10.
# module:
# - ckeditor
format: full_html
editor: ckeditor
settings:
toolbar:
rows:
-
-
name: Formatting
items:
- Bold
- Italic
- Strike
- Superscript
- Subscript
- '-'
- RemoveFormat
-
name: Linking
items:
- DrupalLink
- DrupalUnlink
-
name: Lists
items:
- BulletedList
- NumberedList
-
name: Media
items:
- Blockquote
- DrupalImage
- Table
- HorizontalRule
-
name: 'Block Formatting'
items:
- Format
-
name: Tools
items:
- ShowBlocks
- Source
plugins: {}
image_upload:
status: true
scheme: public
directory: inline-images
max_size: ''
max_dimensions:
width: 0
height: 0

View File

@@ -0,0 +1,44 @@
langcode: en
status: true
dependencies:
module:
- editor
name: 'Basic HTML'
format: basic_html
weight: 0
roles:
- authenticated
filters:
editor_file_reference:
id: editor_file_reference
provider: editor
status: true
weight: 11
settings: { }
filter_align:
id: filter_align
provider: filter
status: true
weight: 7
settings: { }
filter_caption:
id: filter_caption
provider: filter
status: true
weight: 8
settings: { }
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id> <p> <br> <span> <img src alt height width data-entity-type data-entity-uuid data-align data-caption>'
filter_html_help: false
filter_html_nofollow: false
filter_html_image_secure:
id: filter_html_image_secure
provider: filter
status: true
weight: 9
settings: { }

View File

@@ -0,0 +1,35 @@
langcode: en
status: true
dependencies:
module:
- editor
name: 'Full HTML'
format: full_html
weight: 2
roles:
- administrator
filters:
filter_align:
id: filter_align
provider: filter
status: true
weight: 8
settings: { }
filter_caption:
id: filter_caption
provider: filter
status: true
weight: 9
settings: { }
filter_htmlcorrector:
id: filter_htmlcorrector
provider: filter
status: true
weight: 10
settings: { }
editor_file_reference:
id: editor_file_reference
provider: editor
status: true
weight: 11
settings: { }

View File

@@ -0,0 +1,31 @@
langcode: en
status: true
dependencies: { }
name: 'Restricted HTML'
format: restricted_html
weight: 1
roles:
- anonymous
filters:
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>'
filter_html_help: true
filter_html_nofollow: false
filter_autop:
id: filter_autop
provider: filter
status: true
weight: 0
settings: { }
filter_url:
id: filter_url
provider: filter
status: true
weight: 0
settings:
filter_url_length: 72

View File

@@ -0,0 +1 @@
<svg fill="none" height="16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#ffd23f"><path d="M6.5 1h3v9h-3z"/><circle cx="8" cy="13.5" r="1.5"/></g></svg>

After

Width:  |  Height:  |  Size: 164 B

View File

@@ -0,0 +1,75 @@
<?php
/**
* @file
* Test fixture.
*/
use Drupal\Core\Database\Database;
use Drupal\Core\Serialization\Yaml;
$connection = Database::getConnection();
// Update core.extension.
$extensions = $connection->select('config')
->fields('config', ['data'])
->condition('collection', '')
->condition('name', 'core.extension')
->execute()
->fetchField();
$extensions = unserialize($extensions);
$extensions['module']['ckeditor5'] = 0;
$connection->update('config')
->fields(['data' => serialize($extensions)])
->condition('collection', '')
->condition('name', 'core.extension')
->execute();
// Add all ckeditor5_removed_post_updates() as existing updates.
require_once __DIR__ . '/../../../../ckeditor5/ckeditor5.post_update.php';
if (function_exists('ckeditor5_removed_post_updates')) {
$existing_updates = $connection->select('key_value')
->fields('key_value', ['value'])
->condition('collection', 'post_update')
->condition('name', 'existing_updates')
->execute()
->fetchField();
$existing_updates = unserialize($existing_updates);
$existing_updates = array_merge(
$existing_updates,
array_keys(ckeditor5_removed_post_updates()),
);
$connection->update('key_value')
->fields(['value' => serialize($existing_updates)])
->condition('collection', 'post_update')
->condition('name', 'existing_updates')
->execute();
}
$test_format_image_format = Yaml::decode(file_get_contents(__DIR__ . '/filter.format.test_format_image.yml'));
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'filter.format.test_format_image',
'data' => serialize($test_format_image_format),
])
->execute();
$test_format_image_editor = Yaml::decode(file_get_contents(__DIR__ . '/editor.editor.test_format_image.yml'));
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'editor.editor.test_format_image',
'data' => serialize($test_format_image_editor),
])
->execute();

View File

@@ -0,0 +1,54 @@
<?php
/**
* @file
* Test fixture.
*/
use Drupal\Core\Database\Database;
use Drupal\Core\Serialization\Yaml;
$connection = Database::getConnection();
// Update core.extension.
$extensions = $connection->select('config')
->fields('config', ['data'])
->condition('collection', '')
->condition('name', 'core.extension')
->execute()
->fetchField();
$extensions = unserialize($extensions);
$extensions['module']['ckeditor5'] = 0;
$connection->update('config')
->fields(['data' => serialize($extensions)])
->condition('collection', '')
->condition('name', 'core.extension')
->execute();
$test_format_format = Yaml::decode(file_get_contents(__DIR__ . '/filter.format.test_format.yml'));
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'filter.format.test_format',
'data' => serialize($test_format_format),
])
->execute();
$test_format_editor = Yaml::decode(file_get_contents(__DIR__ . '/editor.editor.test_format.yml'));
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'editor.editor.test_format',
'data' => serialize($test_format_editor),
])
->execute();

View File

@@ -0,0 +1,51 @@
<?php
/**
* @file
* Test fixture.
*/
use Drupal\Core\Database\Database;
use Drupal\Core\Serialization\Yaml;
$connection = Database::getConnection();
$format_list_ol_start = Yaml::decode(file_get_contents(__DIR__ . '/filter.format.test_format_list_ol_start.yml'));
$format_list_ol_start_post_3261599 = Yaml::decode(file_get_contents(__DIR__ . '/filter.format.test_format_list_ol_start_post_3261599.yml'));
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'filter.format.test_format_list_ol_start',
'data' => serialize($format_list_ol_start),
])
->values([
'collection' => '',
'name' => 'filter.format.test_format_list_ol_start_post_3261599',
'data' => serialize($format_list_ol_start_post_3261599),
])
->execute();
$editor_list_ol_start = Yaml::decode(file_get_contents(__DIR__ . '/editor.editor.test_format_list_ol_start.yml'));
$editor_list_ol_start_post_3261599 = Yaml::decode(file_get_contents(__DIR__ . '/editor.editor.test_format_list_ol_start_post_3261599.yml'));
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'editor.editor.test_format_list_ol_start',
'data' => serialize($editor_list_ol_start),
])
->values([
'collection' => '',
'name' => 'editor.editor.test_format_list_ol_start_post_3261599',
'data' => serialize($editor_list_ol_start_post_3261599),
])
->execute();

View File

@@ -0,0 +1,22 @@
uuid: f962b8c7-4c74-4100-b6de-08e6a65ff43d
langcode: en
status: true
dependencies:
config:
- filter.format.test_format
module:
- ckeditor5
format: test_format
editor: ckeditor5
settings:
toolbar:
items:
- link
- bold
- italic
- 'alignment:center'
- sourceEditing
plugins:
ckeditor5_sourceEditing:
allowed_tags: { }
image_upload: { }

View File

@@ -0,0 +1,31 @@
uuid: f962b8c7-4c74-4100-b6de-08e6a65ff43c
langcode: en
status: true
dependencies:
config:
- filter.format.test_format_image
module:
- ckeditor5
format: test_format_image
editor: ckeditor5
settings:
toolbar:
items:
- bold
- italic
- uploadImage
- sourceEditing
plugins:
ckeditor5_imageResize:
allow_resize: false
ckeditor5_sourceEditing:
allowed_tags:
- <img data-foo>
image_upload:
status: true
scheme: public
directory: inline-images
max_size: ''
max_dimensions:
width: 0
height: 0

View File

@@ -0,0 +1,20 @@
uuid: f962b8c7-4c74-4100-b6de-08e6a65ff43f
langcode: en
status: true
dependencies:
config:
- filter.format.test_format_list_ol_start
module:
- ckeditor5
format: test_format_list_ol_start
editor: ckeditor5
settings:
toolbar:
items:
- numberedList
- sourceEditing
plugins:
ckeditor5_sourceEditing:
allowed_tags:
- '<ol start foo>'
image_upload: { }

View File

@@ -0,0 +1,26 @@
# Identical to editor.editor.test_format_list_ol_start_post_3261599, except for the plugin configuration.
uuid: f962b8c7-4c74-4100-b6de-18e6a65ff43f
langcode: en
status: true
dependencies:
config:
- filter.format.test_format_list_ol_start_post_3261599
module:
- ckeditor5
format: test_format_list_ol_start_post_3261599
editor: ckeditor5
settings:
toolbar:
items:
- numberedList
- sourceEditing
plugins:
ckeditor5_list:
properties:
reversed: false
startIndex: true
multiBlock: true
ckeditor5_sourceEditing:
allowed_tags:
- '<ol foo>'
image_upload: { }

View File

@@ -0,0 +1,17 @@
uuid: 343a36d6-5852-45f5-b4de-437551cb9caf
langcode: en
status: true
dependencies: { }
name: 'Test format'
format: test_format
weight: 0
filters:
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<br> <p class="text-align-center"> <strong> <em> <a href>'
filter_html_help: true
filter_html_nofollow: false

View File

@@ -0,0 +1,17 @@
uuid: 343a36d6-5852-45f5-b4de-437551cb9cae
langcode: en
status: true
dependencies: { }
name: 'Test format with image toolbar item'
format: test_format_image
weight: 0
filters:
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<br> <p> <strong> <em> <img src alt data-entity-uuid data-entity-type height width data-foo>'
filter_html_help: true
filter_html_nofollow: false

View File

@@ -0,0 +1,17 @@
uuid: 343a36d6-5852-45f5-b4de-437551cb9caf
langcode: en
status: true
dependencies: { }
name: 'Test format — list — <ol start>'
format: test_format_list_ol_start
weight: 0
filters:
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<br> <p> <ol start foo> <li>'
filter_html_help: true
filter_html_nofollow: false

View File

@@ -0,0 +1,18 @@
# Identical to filter.format.test_format_list_ol_start_post_3261599.
uuid: 343a36d6-5852-45f5-b4de-a37551cb9caf
langcode: en
status: true
dependencies: { }
name: 'Test format — list — <ol start> — post-#3261599'
format: test_format_list_ol_start_post_3261599
weight: 0
filters:
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<br> <p> <ol start foo> <li>'
filter_html_help: true
filter_html_nofollow: false

View File

@@ -0,0 +1,11 @@
name: CKEditor 4 to 5 Upgrade plugin Test
type: module
description: "Provides test plugins for testing CKEditor 4 to 5 upgrade infrastructure."
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,51 @@
<?php
/**
* @file
* Implements hooks for the CKEditor 4 to 5 Upgrade plugin Test module.
*/
declare(strict_types = 1);
/**
* Implements hook_ckeditor4to5upgrade_plugin_info_alter().
*/
function ckeditor4to5upgrade_plugin_test_ckeditor4to5upgrade_plugin_info_alter(array &$plugin_definitions): void {
switch (\Drupal::state()->get('ckeditor4to5upgrade_plugin_test')) {
case 'duplicate_button':
$plugin_definitions['foo'] = array_intersect_key($plugin_definitions['core'], ['cke4_buttons' => TRUE]);
break;
case 'duplicate_plugin_settings':
$plugin_definitions['foo'] = array_intersect_key($plugin_definitions['core'], ['cke4_plugin_settings' => TRUE]);
break;
case 'duplicate_subset':
$plugin_definitions['foo'] = array_intersect_key($plugin_definitions['core'], ['cke5_plugin_elements_subset_configuration' => TRUE]);
break;
case 'lying_button':
$plugin_definitions['foo'] = [
'cke4_buttons' => ['foo'],
'class' => $plugin_definitions['core']['class'],
];
break;
case 'lying_plugin_settings':
$plugin_definitions['foo'] = [
'cke4_plugin_settings' => ['foo'],
'class' => $plugin_definitions['core']['class'],
];
break;
case 'lying_subset':
$plugin_definitions['foo'] = [
'cke5_plugin_elements_subset_configuration' => ['foo'],
'class' => $plugin_definitions['core']['class'],
];
break;
default:
throw new \LogicException();
}
}

View File

@@ -0,0 +1,17 @@
ckeditor5_automatic_link_decorator_test_llamaClass:
ckeditor5:
plugins: []
config:
link:
decorators:
pinkColor:
mode: 'automatic'
attributes:
class: 'llama'
drupal:
label: Links must have 'llama' class!
elements:
- <a class>
conditions:
plugins:
- ckeditor5_link

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Automatic Link Decorator Test
type: module
description: "Provides infrastructure for testing CKEditor 5 automatic link decorators."
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,13 @@
ckeditor5_automatic_link_decorator_test_2_addTargetToExternalLinks:
ckeditor5:
plugins: []
config:
link:
addTargetToExternalLinks: true
drupal:
label: Open external links in a new tab
elements:
- <a target="_blank" rel="noopener noreferrer">
conditions:
plugins:
- ckeditor5_link

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Automatic Link Decorator Test (External links)
type: module
description: "Provides infrastructure for testing CKEditor 5 external links automatic link decorator."
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,111 @@
ckeditor5_definition_supporting_element_just_nav:
ckeditor5:
plugins: []
drupal:
label: TEST — <nav>
elements:
- <nav>
ckeditor5_definition_supporting_element_just_article:
ckeditor5:
plugins: []
drupal:
label: TEST — <article>
elements:
- <article>
ckeditor5_definition_supporting_element_article_class:
ckeditor5:
plugins: []
drupal:
label: TEST — <article class>
elements:
- <article class>
ckeditor5_definition_supporting_element_article_class_with_values:
ckeditor5:
plugins: []
drupal:
label: TEST — <article class="…">
elements:
- <article class="this-value that-value">
ckeditor5_definition_supporting_element_just_footer:
ckeditor5:
plugins: []
drupal:
label: TEST — <footer>
elements:
- <footer>
ckeditor5_definition_supporting_element_footer_class:
ckeditor5:
plugins: []
drupal:
label: TEST — <footer class>
elements:
- <footer class>
ckeditor5_definition_supporting_element_just_aside:
ckeditor5:
plugins: []
drupal:
label: TEST — <aside>
elements:
- <aside>
ckeditor5_definition_supporting_element_aside_class_with_values:
ckeditor5:
plugins: []
drupal:
label: TEST — <article class="…">
elements:
- <aside class="this-value that-value">
ckeditor5_definition_supporting_element_main_class:
ckeditor5:
plugins: []
drupal:
label: TEST — <main class>
elements:
- <main class>
ckeditor5_definition_supporting_element_main_class_with_values:
ckeditor5:
plugins: []
drupal:
label: TEST — <main class="…">
elements:
- <main class="this-value that-value">
ckeditor5_definition_supporting_element_figure_one_attrib:
ckeditor5:
plugins: []
drupal:
label: TEST — <figure data-one>
elements:
- <figure data-one>
ckeditor5_definition_supporting_element_figure_two_attrib:
ckeditor5:
plugins: []
drupal:
label: TEST — <figure data-one data-two>
elements:
- <figure data-one data-two>
ckeditor5_definition_supporting_element_dialog_two_attrib:
ckeditor5:
plugins: []
drupal:
label: TEST — <dialog data-one data-two>
elements:
- <dialog data-one data-two>
ckeditor5_definition_supporting_element_dialog_one_attrib:
ckeditor5:
plugins: []
drupal:
label: TEST — <dialog data-one>
elements:
- <dialog data-one>

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Definition Supporting Tags Test
type: module
description: "Provides test plugins for CKEditor 5 to test finding plugin definitions that support specific tags."
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,12 @@
name: CKEditor 5 Drupal Element Style Test
type: module
description: "Provides ability to run DrupalElementStyle CKEditor 5 plugin in multiple ways."
package: Testing
dependencies:
- drupal:ckeditor5
- drupal:media
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,31 @@
<?php
/**
* @file
* Implements hooks for the CKEditor 5 Drupal Element Style Test module.
*/
declare(strict_types = 1);
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
/**
* Implements hook_ckeditor4to5upgrade_plugin_info_alter().
*/
function ckeditor5_drupalelementstyle_test_ckeditor5_plugin_info_alter(array &$plugin_definitions): void {
// Update `media_mediaAlign`.
assert($plugin_definitions['media_mediaAlign'] instanceof CKEditor5PluginDefinition);
$media_align_plugin_definition = $plugin_definitions['media_mediaAlign']->toArray();
$media_align_plugin_definition['ckeditor5']['config']['drupalMedia']['toolbar'] = [
0 => [
'name' => 'drupalMedia:align',
'title' => 'Test title',
'display' => 'splitButton',
'items' => array_values(array_filter($media_align_plugin_definition['ckeditor5']['config']['drupalMedia']['toolbar'], function (string $toolbar_item): bool {
return $toolbar_item !== '|';
})),
'defaultItem' => 'drupalElementStyle:align:breakText',
],
];
$plugin_definitions['media_mediaAlign'] = new CKEditor5PluginDefinition($media_align_plugin_definition);
}

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Incompatible Filter Test
type: module
description: "Provides a filter incompatible with CKEditor 5"
package: Testing
dependencies:
- drupal:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,28 @@
<?php
namespace Drupal\ckeditor5_incompatible_filter_test\Plugin\Filter;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\filter\Attribute\Filter;
use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
use Drupal\filter\Plugin\FilterInterface;
/**
* Provides a filter incompatible with CKEditor 5.
*/
#[Filter(
id: "filter_incompatible",
title: new TranslatableMarkup("A TYPE_MARKUP_LANGUAGE filter incompatible with CKEditor 5"),
type: FilterInterface::TYPE_MARKUP_LANGUAGE
)]
class FilterIsIncompatible extends FilterBase {
/**
* {@inheritdoc}
*/
public function process($text, $langcode) {
return new FilterProcessResult($text);
}
}

View File

@@ -0,0 +1,25 @@
ckeditor5_manual_decorator_test_openInNewTab:
ckeditor5:
plugins: []
config:
link:
decorators:
openInNewTab:
mode: 'manual'
label: 'Open in a new tab'
attributes:
target: '_blank'
rel: 'noopener noreferrer'
classes: ['link-new-tab']
pinkColor:
mode: 'manual'
label: 'Pink color'
styles:
color: 'pink'
drupal:
label: Open in new tab
elements:
- <a target="_blank" rel="noopener noreferrer" class>
conditions:
plugins:
- ckeditor5_link

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Manual Decorator Test
type: module
description: "Provides configuration for CKEditor 5 link plugin manual decorator."
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,26 @@
ckeditor5_plugin_conditions_test_plugins_condition:
ckeditor5:
plugins: {}
drupal:
label: TEST — Plugins Condition
toolbar_items:
fooBarConditions:
label: Foo Bar (Test Plugins Condition)
conditions:
plugins:
- ckeditor5_heading
- ckeditor5_table
elements:
- <foo>
ckeditor5_plugin_conditions_test_plugin_allow_all_classes_on_kbd:
ckeditor5:
plugins: {}
drupal:
label: TEST — Allow all classes on kbd
toolbar_items:
kbdAllClasses:
label: All classes on kbd (Test Plugins Condition)
elements:
- <kbd>
- <kbd class>

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Plugin Conditions Test
type: module
description: "Provides test plugins for CKEditor 5 to test plugin conditions."
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,11 @@
ckeditor5_plugin_elements_subset_sneakySuperset:
ckeditor5:
plugins: []
drupal:
label: Sneaky Superset
class: Drupal\ckeditor5_plugin_elements_subset\Plugin\CKEditor5Plugin\SneakySuperset
elements:
- <foo>
- <bar>
- <bar baz>
- <$any-html5-element class>

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Plugin Elements Subset Test
type: module
description: "Provides test plugin to test CKEditor5PluginElementsSubsetInterface"
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5_plugin_elements_subset\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\Core\Form\FormStateInterface;
class SneakySuperset extends CKEditor5PluginDefault implements CKEditor5PluginElementsSubsetInterface {
use CKEditor5PluginConfigurableTrait;
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
return [];
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'configured_subset' => [],
];
}
/**
* {@inheritdoc}
*/
public function getElementsSubset(): array {
return $this->configuration['configured_subset'];
}
}

View File

@@ -0,0 +1,33 @@
# cspell:ignore everytextcontainer justheading
ckeditor5_plugin_elements_test_headingCombo:
ckeditor5:
plugins: []
drupal:
label: TEST — block quote combo
elements:
- <h1 data-justheading>
- <$text-container data-everytextcontainer>
ckeditor5_plugin_elements_test_headingsWithOtherAttributes:
ckeditor5:
plugins: []
drupal:
label: TEST — headings with other attributes
elements:
- <h1 data-just-h1>
- <h2 class="additional-allowed-class">
- <h3 data-just-h3 data-just-h3-limited="i-am-the-only-allowed-value">
- <h5 data-just-h5-limited="first-allowed-value second-allowed-value">
ckeditor5_plugin_elements_test_headingsUseClassAnyValue:
ckeditor5:
plugins: []
drupal:
label: TEST — headings with any class
elements:
- <h1 class>
- <h2 class>
- <h3 class>
- <h4 class>
- <h5 class>
- <h6 class>

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Plugin Elements Test
type: module
description: "Provides test plugins for CKEditor 5 to test allowed elements parsing."
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 read-only mode test
type: module
description: "Provides code for testing disabled CKEditor 5 editors."
package: Testing
dependencies:
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,18 @@
<?php
/**
* @file
* Implements hooks for the CKEditor 5 read-only mode test module.
*/
declare(strict_types = 1);
use Drupal\Core\Form\FormStateInterface;
/**
* Implements hook_form_alter().
*/
function ckeditor5_read_only_mode_form_node_page_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void {
$form['body']['#disabled'] = \Drupal::state()->get('ckeditor5_read_only_mode_body_enabled', FALSE);
$form['field_second_ckeditor5_field']['#disabled'] = \Drupal::state()->get('ckeditor5_read_only_mode_second_ckeditor5_field_enabled', FALSE);
}

View File

@@ -0,0 +1,29 @@
# cspell:ignore layercake
ckeditor5_test_layercake:
ckeditor5:
plugins: []
config:
drupalElementStyles:
layercake:
- name: 'layerCakeSide'
title: 'Media aligned to side'
icon: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2z"/><path d="M18.003 7v5.5a1 1 0 0 1-1 1H8.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H9.5V12h6.997V7.5z"/></svg>'
attributeName: 'class'
attributeValue: 'layercake-side'
modelElements: ['drupalMedia']
drupalMediaStyles:
toolbar:
- drupalElementStyle:layerCakeSide
drupal:
label: TEST — Layercake
library: ckeditor5_test/layercake
toolbar_items:
simpleBox:
label: Simple Box
twoCol:
label: Two Col layout
elements:
- <h1 class>
- <div class>
- <section class>
- <drupal-media class="layercake-side">

View File

@@ -0,0 +1,12 @@
name: CKEditor 5 Test
type: module
description: "Provides test layout and component plugins for CKEditor 5."
package: Testing
dependencies:
- drupal:editor
- ckeditor5:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,10 @@
# cspell:ignore layercake
layercake:
version: VERSION
# In real-world (non-test) scenarios, this would load the CKEditor 5 plugin's built JS.
js: {}
css:
theme:
css/layout.css: {}
dependencies:
- core/ckeditor5

View File

@@ -0,0 +1,13 @@
ckeditor5_test.off_canvas:
path: '/ckeditor5_test/off_canvas'
defaults:
_controller: '\Drupal\ckeditor5_test\Controller\CKEditor5OffCanvasTestController::testOffCanvas'
requirements:
_access: 'TRUE'
ckeditor5_test.dialog:
path: '/ckeditor5_test/dialog'
defaults:
_controller: '\Drupal\ckeditor5_test\Controller\CKEditor5DialogTestController::testDialog'
requirements:
_access: 'TRUE'

View File

@@ -0,0 +1,14 @@
.layout--two-col {
display: grid;
grid-template-columns: 1fr 1fr;
}
.simple-box {
padding: 0.5rem;
background: #ccc;
}
.simple-box-title,
.simple-box-description {
background: #fff;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5_test\Controller;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
/**
* Provides controller for testing CKEditor in off-canvas dialogs.
*/
class CKEditor5DialogTestController {
/**
* Returns a link that can open a node add form in an modal dialog.
*
* @return array
* A render array.
*/
public function testDialog() {
$build['link'] = [
'#type' => 'link',
'#title' => 'Add Node',
'#url' => Url::fromRoute('node.add', ['node_type' => 'page']),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'dialog',
'data-dialog-options' => Json::encode([
'width' => 700,
'modal' => TRUE,
'autoResize' => TRUE,
]),
],
];
$build['#attached']['library'][] = 'core/drupal.dialog.ajax';
return $build;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5_test\Controller;
use Drupal\Core\Url;
/**
* Provides controller for testing CKEditor in off-canvas dialogs.
*/
class CKEditor5OffCanvasTestController {
/**
* Returns a link that can open a node add form in an off-canvas dialog.
*
* @return array
* A render array.
*/
public function testOffCanvas() {
$build['link'] = [
'#type' => 'link',
'#title' => 'Add Node',
'#url' => Url::fromRoute('node.add', ['node_type' => 'page']),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'dialog',
'data-dialog-renderer' => 'off_canvas',
],
];
$build['#attached']['library'][] = 'core/drupal.dialog.off_canvas';
return $build;
}
}

View File

@@ -0,0 +1,11 @@
name: CKEditor 5 Module Allowed Image
type: module
description: Alters the allowed image types.
package: Testing
dependencies:
- drupal:ckeditor5
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,22 @@
<?php
/**
* @file
* A module to add a custom image type for CKEditor 5.
*/
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
/**
* Implements hook_ckeditor5_plugin_info_alter().
*/
function ckeditor5_test_module_allowed_image_ckeditor5_plugin_info_alter(array &$plugin_definitions): void {
// Add a custom file type to the image upload plugin. Note that 'svg+xml'
// below should be an IANA image media type Name, with the "image/" prefix
// omitted. In other words: a subtype of type image.
// @see https://www.iana.org/assignments/media-types/media-types.xhtml#image
// @see https://ckeditor.com/docs/ckeditor5/latest/api/module_image_imageconfig-ImageUploadConfig.html#member-types
$image_upload_plugin_definition = $plugin_definitions['ckeditor5_imageUpload']->toArray();
$image_upload_plugin_definition['ckeditor5']['config']['image']['upload']['types'][] = 'svg+xml';
$plugin_definitions['ckeditor5_imageUpload'] = new CKEditor5PluginDefinition($image_upload_plugin_definition);
}

View File

@@ -0,0 +1,11 @@
name: CKEditor Test
type: module
description: "Provides test a test editor that with the ID ckeditor."
package: Testing
dependencies:
- drupal:editor_test
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,17 @@
<?php
/**
* @file
* Implements hooks for the CKEditor test module.
*/
declare(strict_types = 1);
/**
* Implements hook_editor_info_alter().
*/
function ckeditor_test_editor_info_alter(array &$editors) {
// Drupal 9 used to have an editor called ckeditor. Copy the Unicorn editor to
// it to be able to test upgrading to CKEditor 5.
$editors['ckeditor'] = $editors['unicorn'];
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\RoleInterface;
use Drupal\user\Entity\User;
use Symfony\Component\Validator\ConstraintViolation;
/**
* Test the ckeditor5-stylesheets theme config property.
*
* @group ckeditor5
*/
class AddedStylesheetsTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The editor user.
*
* @var \Drupal\editor\Entity\Editor
*/
protected Editor $editor;
/**
* The admin user.
*
* @var \Drupal\user\Entity\User
*/
protected User $adminUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$filtered_html_format = FilterFormat::create([
'format' => 'llama',
'name' => 'Llama',
'filters' => [],
'roles' => [RoleInterface::AUTHENTICATED_ID],
]);
$filtered_html_format->save();
$this->editor = Editor::create([
'format' => 'llama',
'editor' => 'ckeditor5',
'settings' => [
'toolbar' => [
'items' => [],
],
],
]);
$this->editor->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair($this->editor, $filtered_html_format))
));
// Create node type.
$this->drupalCreateContentType([
'type' => 'article',
'name' => 'Article',
]);
$this->adminUser = $this->drupalCreateUser([
'create article content',
'use text format llama',
'administer themes',
'view the administration theme',
'administer filters',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Test the ckeditor5-stylesheets theme config.
*/
public function testCkeditorStylesheets(): void {
$assert_session = $this->assertSession();
/** @var \Drupal\Core\Extension\ThemeInstallerInterface $theme_installer */
$theme_installer = \Drupal::service('theme_installer');
$theme_installer->install(['test_ckeditor_stylesheets_relative', 'claro']);
$this->config('system.theme')->set('admin', 'claro')->save();
$this->config('node.settings')->set('use_admin_theme', TRUE)->save();
$this->drupalGet('node/add/article');
$assert_session->responseNotContains('test_ckeditor_stylesheets_relative/css/yokotsoko.css');
// Confirm that the missing ckeditor5-stylesheets configuration can be
// bypassed.
$this->drupalGet('admin/config/content/formats/manage/llama');
$assert_session->pageTextNotContains('ckeditor_stylesheets configured without a corresponding ckeditor5-stylesheets configuration.');
// Install a theme with ckeditor5-stylesheets configured. Do this manually
// to confirm `library_info` cache tags are invalidated.
$this->drupalGet('admin/appearance');
$this->clickLink('Set Test relative CKEditor stylesheets as default theme');
// Confirm the stylesheet added via `ckeditor5-stylesheets` is present.
$this->drupalGet('node/add/article');
$assert_session->responseContains('test_ckeditor_stylesheets_relative/css/yokotsoko.css');
// Change the default theme to Stark, and confirm the stylesheet added via
// `ckeditor5-stylesheets` is no longer present.
$this->drupalGet('admin/appearance');
$this->clickLink('Set Stark as default theme');
$this->drupalGet('node/add/article');
$assert_session->responseNotContains('test_ckeditor_stylesheets_relative/css/yokotsoko.css');
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for ckeditor5.
*
* @group ckeditor5
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\File\FileExists;
/**
* Test image upload access.
*
* @group ckeditor5
* @internal
*/
class ImageUploadAccessTest extends ImageUploadTest {
/**
* Test access to the CKEditor 5 image upload controller.
*/
public function testCkeditor5ImageUploadRoute(): void {
$this->createBasicFormat();
$url = $this->getUploadUrl();
$test_image = file_get_contents(current($this->getTestFiles('image'))->uri);
// With no text editor, expect a 404.
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(404, $response->getStatusCode());
$editor = $this->createEditorWithUpload(['status' => FALSE]);
// Ensure that images cannot be uploaded when image upload is disabled.
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(403, $response->getStatusCode());
$editor->setImageUploadSettings([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => 0,
'height' => 0,
],
])->save();
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(201, $response->getStatusCode());
// Ensure lock failures are reported correctly.
$d = 'public://inline-images/test.jpg';
$f = $this->container->get('file_system')->getDestinationFilename($d, FileExists::Rename);
$this->container->get('lock')
->acquire('file:ckeditor5:' . Crypt::hashBase64($f));
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(503, $response->getStatusCode());
$this->assertStringContainsString('File &quot;public://inline-images/test_0.jpg&quot; is already locked for writing.', (string) $response->getBody());
// Ensure that users without permissions to the text format cannot upload
// images.
$this->drupalLogout();
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(403, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\Core\Url;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\ckeditor5\Traits\SynchronizeCsrfTokenSeedTrait;
use Drupal\Tests\jsonapi\Functional\JsonApiRequestTestTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\RoleInterface;
use GuzzleHttp\RequestOptions;
/**
* Test image upload.
*
* @group ckeditor5
* @internal
*/
class ImageUploadTest extends BrowserTestBase {
use JsonApiRequestTestTrait;
use TestFileCreationTrait;
use SynchronizeCsrfTokenSeedTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'editor',
'filter',
'ckeditor5',
];
/**
* A user without any particular permissions to be used in testing.
*
* @var \Drupal\user\Entity\User
*/
protected $user;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->user = $this->drupalCreateUser();
$this->drupalLogin($this->user);
}
/**
* Tests using the file upload route with a disallowed extension.
*/
public function testUploadFileExtension(): void {
$this->createBasicFormat();
$this->createEditorWithUpload([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => 0,
'height' => 0,
],
]);
$url = $this->getUploadUrl();
$image_file = file_get_contents(current($this->getTestFiles('image'))->uri);
$non_image_file = file_get_contents(current($this->getTestFiles('php'))->uri);
$response = $this->uploadRequest($url, $non_image_file, 'test.php');
$this->assertSame(422, $response->getStatusCode());
$response = $this->uploadRequest($url, $image_file, 'test.jpg');
$this->assertSame(201, $response->getStatusCode());
}
/**
* Tests using the file upload route with a file size larger than allowed.
*/
public function testFileUploadLargerFileSize(): void {
$this->createBasicFormat();
$this->createEditorWithUpload([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => 30000,
'max_dimensions' => [
'width' => 0,
'height' => 0,
],
]);
$url = $this->getUploadUrl();
$images = $this->getTestFiles('image');
$large_image = $this->getTestImageByStat($images, 'size', function ($size) {
return $size > 30000;
});
$small_image = $this->getTestImageByStat($images, 'size', function ($size) {
return $size < 30000;
});
$response = $this->uploadRequest($url, file_get_contents($large_image->uri), 'large.jpg');
$this->assertSame(422, $response->getStatusCode());
$response = $this->uploadRequest($url, file_get_contents($small_image->uri), 'small.jpg');
$this->assertSame(201, $response->getStatusCode());
}
/**
* Test that lock is removed after a failed validation.
*
* @see https://www.drupal.org/project/drupal/issues/3184974
*/
public function testLockAfterFailedValidation(): void {
$this->createBasicFormat();
$this->createEditorWithUpload([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => 30000,
'max_dimensions' => [
'width' => 0,
'height' => 0,
],
]);
$url = $this->getUploadUrl();
$images = $this->getTestFiles('image');
$large_image = $this->getTestImageByStat($images, 'size', function ($size) {
return $size > 30000;
});
$small_image = $this->getTestImageByStat($images, 'size', function ($size) {
return $size < 30000;
});
$response = $this->uploadRequest($url, file_get_contents($large_image->uri), 'same.jpg');
$this->assertSame(422, $response->getStatusCode());
$response = $this->uploadRequest($url, file_get_contents($small_image->uri), 'same.jpg');
$this->assertSame(201, $response->getStatusCode());
}
/**
* Make upload request to a controller.
*
* @param \Drupal\Core\Url $url
* The URL for the request.
* @param string $file_contents
* File contents.
* @param string $file_name
* Name of the file.
*
* @return \Psr\Http\Message\ResponseInterface
* The response.
*/
protected function uploadRequest(Url $url, string $file_contents, string $file_name) {
$request_options[RequestOptions::HEADERS] = [
'Accept' => 'application/json',
];
$request_options[RequestOptions::MULTIPART] = [
[
'name' => 'upload',
'filename' => $file_name,
'contents' => $file_contents,
],
];
return $this->request('POST', $url, $request_options);
}
/**
* Provides the image upload URL.
*
* @return \Drupal\Core\Url
* The upload image URL for the basic_html format.
*/
protected function getUploadUrl() {
$token = $this->container->get('csrf_token')->get('ckeditor5/upload-image/basic_html');
return Url::fromRoute('ckeditor5.upload_image', ['editor' => 'basic_html'], ['query' => ['token' => $token]]);
}
/**
* Create a basic_html text format for the editor to reference.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
protected function createBasicFormat() {
$basic_html_format = FilterFormat::create([
'format' => 'basic_html',
'name' => 'Basic HTML',
'weight' => 1,
'filters' => [
'filter_html_escape' => ['status' => 1],
],
'roles' => [RoleInterface::AUTHENTICATED_ID],
]);
$basic_html_format->save();
}
/**
* Create an editor entity with image_upload config.
*
* @param array $upload_config
* The editor image_upload config.
*
* @return \Drupal\Core\Entity\EntityBase|\Drupal\Core\Entity\EntityInterface
* The text editor entity.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
protected function createEditorWithUpload(array $upload_config) {
$editor = Editor::create([
'editor' => 'ckeditor5',
'format' => 'basic_html',
'settings' => [
'toolbar' => [
'items' => [
'drupalInsertImage',
],
],
'plugins' => [
'ckeditor5_imageResize' => [
'allow_resize' => FALSE,
],
],
],
'image_upload' => $upload_config,
]);
$editor->save();
return $editor;
}
/**
* Return the first image matching $condition.
*
* @param array $images
* Images created with getTestFiles().
* @param string $stat
* A key in the array returned from stat().
* @param callable $condition
* A function to compare a value of the image file.
*
* @return object|bool
* Objects with 'uri', 'filename', and 'name' properties.
*/
protected function getTestImageByStat(array $images, string $stat, callable $condition) {
return current(array_filter($images, function ($image) use ($condition, $stat) {
$stats = stat($image->uri);
return $condition($stats[$stat]);
}));
}
}

View File

@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\Core\Entity\Entity\EntityViewMode;
use Drupal\editor\Entity\Editor;
use Drupal\field\Entity\FieldConfig;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\language\Entity\ContentLanguageSettings;
use Drupal\media\Entity\Media;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\ckeditor5\Traits\SynchronizeCsrfTokenSeedTrait;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\RoleInterface;
use Drupal\user\Entity\User;
use Symfony\Component\Validator\ConstraintViolation;
/**
* Tests the media entity metadata API.
*
* @group ckeditor5
* @internal
*/
class MediaEntityMetadataApiTest extends BrowserTestBase {
use TestFileCreationTrait;
use MediaTypeCreationTrait;
use SynchronizeCsrfTokenSeedTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = [
'system',
'user',
'filter',
'editor',
'ckeditor5',
'media',
];
/**
* The sample image media entity to use for testing.
*
* @var \Drupal\media\MediaInterface
*/
protected $mediaImage;
/**
* The sample file media entity to use for testing.
*
* @var \Drupal\media\MediaInterface
*/
protected $mediaFile;
/**
* The editor instance to use for testing.
*
* @var \Drupal\editor\Entity\Editor
*/
protected $editor;
/**
* The admin user.
*
* @var \Drupal\user\Entity\User
*/
protected User $adminUser;
/**
* @var \Drupal\Component\Uuid\UuidInterface
*/
protected $uuidService;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->uuidService = $this->container->get('uuid');
EntityViewMode::create([
'id' => 'media.view_mode_1',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 1',
])->save();
EntityViewMode::create([
'id' => 'media.view_mode_2',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 2',
])->save();
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [
'filter_html' => [
'id' => 'filter_html',
'status' => TRUE,
'weight' => -10,
'settings' => [
'allowed_html' => "<p> <br> <drupal-media data-entity-type data-entity-uuid data-view-mode alt>",
'filter_html_help' => TRUE,
'filter_html_nofollow' => TRUE,
],
],
'media_embed' => [
'id' => 'media_embed',
'status' => TRUE,
'settings' => [
'default_view_mode' => 'view_mode_1',
'allowed_view_modes' => [
'view_mode_1' => 'view_mode_1',
'view_mode_2' => 'view_mode_2',
],
'allowed_media_types' => [],
],
],
],
'roles' => [RoleInterface::AUTHENTICATED_ID],
]);
$filtered_html_format->save();
$this->editor = Editor::create([
'format' => 'filtered_html',
'editor' => 'ckeditor5',
'settings' => [
'toolbar' => [
'items' => [],
],
'plugins' => [
'media_media' => [
'allow_view_mode_override' => TRUE,
],
],
],
]);
$this->editor->save();
$filtered_html_format->setFilterConfig('media_embed', [
'status' => TRUE,
'settings' => [
'default_view_mode' => 'view_mode_1',
'allowed_media_types' => [],
'allowed_view_modes' => [
'view_mode_1' => 'view_mode_1',
'view_mode_2' => 'view_mode_2',
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair($this->editor, $filtered_html_format))
));
// Create a sample media entity to be embedded.
$this->createMediaType('image', ['id' => 'image']);
File::create([
'uri' => $this->getTestFiles('image')[0]->uri,
])->save();
$this->mediaImage = Media::create([
'bundle' => 'image',
'name' => 'Screaming hairy armadillo',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'default alt',
'title' => 'default title',
],
],
]);
$this->mediaImage->save();
$this->createMediaType('file', ['id' => 'file']);
File::create([
'uri' => $this->getTestFiles('text')[0]->uri,
])->save();
$this->mediaFile = Media::create([
'bundle' => 'file',
'name' => 'Information about screaming hairy armadillo',
'field_media_file' => [
[
'target_id' => 2,
],
],
]);
$this->mediaFile->save();
$this->adminUser = $this->drupalCreateUser([
'use text format filtered_html',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Tests the media entity metadata API.
*/
public function testApi(): void {
$path = '/ckeditor5/filtered_html/media-entity-metadata';
$token = $this->container->get('csrf_token')->get(ltrim($path, '/'));
$uuid = $this->mediaImage->uuid();
$this->drupalGet($path, ['query' => ['token' => $token]]);
$this->assertSession()->statusCodeEquals(400);
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(["type" => "image", 'imageSourceMetadata' => ['alt' => 'default alt']]), $this->getSession()->getPage()->getContent());
$this->mediaImage->set('field_media_image', [
'target_id' => 1,
'alt' => '',
'title' => 'default title',
])->save();
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(['type' => 'image', 'imageSourceMetadata' => ['alt' => '']]), $this->getSession()->getPage()->getContent());
// Test that setting the media image field to not display alt field also
// omits it from the API (which will in turn instruct the CKE5 plugin to not
// show it).
FieldConfig::loadByName('media', 'image', 'field_media_image')
->setSetting('alt_field', FALSE)
->save();
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(['type' => 'image']), $this->getSession()->getPage()->getContent());
$this->drupalGet($path, ['query' => ['uuid' => $this->mediaFile->uuid(), 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(['type' => 'file']), $this->getSession()->getPage()->getContent());
// Ensure that unpublished media returns 403.
$this->mediaImage->setUnpublished()->save();
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(403);
// Ensure that valid, but non-existing UUID returns 404.
$this->drupalGet($path, ['query' => ['uuid' => $this->uuidService->generate(), 'token' => $token]]);
$this->assertSession()->statusCodeEquals(404);
// Ensure that invalid UUID returns 400.
$this->drupalGet($path, ['query' => ['uuid' => '🦙', 'token' => $token]]);
$this->assertSession()->statusCodeEquals(400);
// Ensure that users that don't have access to the filter format receive
// either 404 or 403.
$this->drupalLogout();
$token = $this->container->get('csrf_token')->get(ltrim($path, '/'));
$this->drupalGet($path, ['token' => $token]);
$this->assertSession()->statusCodeEquals(400);
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(403);
$this->mediaImage->setPublished()->save();
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(403);
}
/**
* Tests the media entity metadata API with translations.
*/
public function testApiTranslation(): void {
$this->container->get('module_installer')->install(['language', 'content_translation']);
$this->resetAll();
ConfigurableLanguage::createFromLangcode('fi')->save();
$this->container->get('config.factory')->getEditable('language.negotiation')
->set('url.source', 'path_prefix')
->set('url.prefixes.fi', 'fi')
->save();
$this->rebuildContainer();
ContentLanguageSettings::loadByEntityTypeBundle('media', 'image')
->setDefaultLangcode('en')
->setLanguageAlterable(TRUE)
->save();
$media_fi = Media::load($this->mediaImage->id())->addTranslation('fi');
$media_fi->field_media_image->setValue([
[
'target_id' => '1',
// cSpell:disable-next-line
'alt' => 'oletus alt-teksti kuvalle',
],
]);
$media_fi->save();
$uuid = $this->mediaImage->uuid();
$path = '/ckeditor5/filtered_html/media-entity-metadata';
$token = $this->container->get('csrf_token')->get(ltrim($path, '/'));
// Ensure that translation is returned when language is specified.
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token], 'language' => $media_fi->language()]);
$this->assertSession()->statusCodeEquals(200);
// cSpell:disable-next-line
$this->assertSame(json_encode(['type' => 'image', 'imageSourceMetadata' => ['alt' => 'oletus alt-teksti kuvalle']]), $this->getSession()->getPage()->getContent());
// Ensure that default translation is returned when no language is
// specified.
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(['type' => 'image', 'imageSourceMetadata' => ['alt' => 'default alt']]), $this->getSession()->getPage()->getContent());
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional\Update;
use Drupal\editor\Entity\Editor;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* @covers ckeditor5_post_update_code_block
* @group Update
* @group ckeditor5
*/
class CKEditor5UpdateCodeBlockConfigurationTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.filled.standard.php.gz',
];
}
/**
* Ensure default configuration for the CKEditor 5 codeBlock plugin is added.
*/
public function testUpdateCodeBlockConfigurationPostUpdate(): void {
$editor = Editor::load('full_html');
$settings = $editor->getSettings();
$this->assertArrayNotHasKey('ckeditor5_codeBlock', $settings['plugins']);
$this->runUpdates();
$editor = Editor::load('full_html');
$settings = $editor->getSettings();
$this->assertArrayHasKey('ckeditor5_codeBlock', $settings['plugins']);
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\CodeBlock::defaultConfiguration()
$this->assertSame([
'languages' => [
['label' => 'Plain text', 'language' => 'plaintext'],
['label' => 'C', 'language' => 'c'],
['label' => 'C#', 'language' => 'cs'],
['label' => 'C++', 'language' => 'cpp'],
['label' => 'CSS', 'language' => 'css'],
['label' => 'Diff', 'language' => 'diff'],
['label' => 'HTML', 'language' => 'html'],
['label' => 'Java', 'language' => 'java'],
['label' => 'JavaScript', 'language' => 'javascript'],
['label' => 'PHP', 'language' => 'php'],
['label' => 'Python', 'language' => 'python'],
['label' => 'Ruby', 'language' => 'ruby'],
['label' => 'TypeScript', 'language' => 'typescript'],
['label' => 'XML', 'language' => 'xml'],
],
], $settings['plugins']['ckeditor5_codeBlock']);
}
}

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional\Update;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\filter\FilterFormatInterface;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Symfony\Component\Validator\ConstraintViolation;
/**
* Tests the update path for the CKEditor 5 image toolbar item.
*
* @group Update
* @group #slow
*/
class CKEditor5UpdateImageToolbarItemTest extends UpdatePathTestBase {
use CKEditor5TestTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.bare.standard.php.gz',
__DIR__ . '/../../../fixtures/update/ckeditor5-3222756.php',
];
}
/**
* Tests that `uploadImage` toolbar item is updated to `drupalInsertImage`.
*
* @dataProvider provider
*/
public function test(bool $filter_html_is_enabled, bool $image_uploads_are_enabled, bool $source_editing_is_already_enabled, array $expected_source_editing_additions): void {
// Apply tweaks for the currently provided test case.
$format = FilterFormat::load('test_format_image');
if (!$filter_html_is_enabled) {
$format->setFilterConfig('filter_html', ['status' => FALSE]);
}
$editor = Editor::load('test_format_image');
if (!$image_uploads_are_enabled) {
$editor->setImageUploadSettings(['status' => FALSE]);
}
if (!$source_editing_is_already_enabled) {
$settings = $editor->getSettings();
// Remove the `sourceEditing` toolbar item.
unset($settings['toolbar']['items'][3]);
// Remove the corresponding plugin settings (allowing `<img data-foo>`).
unset($settings['plugins']['ckeditor5_sourceEditing']);
$editor->setSettings($settings);
if ($filter_html_is_enabled) {
// Stop allowing `<img data-foo>`.
$filter_html_config = $format->filters('filter_html')
->getConfiguration();
$filter_html_config['settings']['allowed_html'] = str_replace('data-foo', '', $filter_html_config['settings']['allowed_html']);
$format->setFilterConfig('filter_html', $filter_html_config);
}
}
$format->trustData()->save();
$editor->trustData()->save();
// Run update path; snapshot the Text Format and Editor before and after.
$editor_before = Editor::load('test_format_image');
$filter_format_before = $editor->getFilterFormat();
$this->runUpdates();
$editor_after = Editor::load('test_format_image');
$filter_format_after = $editor->getFilterFormat();
// 1. Toolbar item: `uploadImage` -> `drupalInsertImage`, position must be
// unchanged.
$this->assertContains('uploadImage', $editor_before->getSettings()['toolbar']['items']);
$this->assertNotContains('drupalInsertImage', $editor_before->getSettings()['toolbar']['items']);
$this->assertNotContains('uploadImage', $editor_after->getSettings()['toolbar']['items']);
$this->assertContains('drupalInsertImage', $editor_after->getSettings()['toolbar']['items']);
$this->assertSame(
array_search('uploadImage', $editor_before->getSettings()['toolbar']['items']),
array_search('drupalInsertImage', $editor_after->getSettings()['toolbar']['items'])
);
// 2. Even though `sourceEditing` may not be enabled before this update, it
// must be after, at least if image uploads are disabled: extra mark-up will
// be added to its configuration to avoid breaking backwards compatibility.
if (!$image_uploads_are_enabled) {
if (!$source_editing_is_already_enabled) {
$this->assertNotContains('sourceEditing', $editor_before->getSettings()['toolbar']['items']);
}
$this->assertContains('sourceEditing', $editor_after->getSettings()['toolbar']['items']);
$source_editing_before = $source_editing_is_already_enabled
? static::getSourceEditingRestrictions($editor_before)
: HTMLRestrictions::emptySet();
$source_editing_after = static::getSourceEditingRestrictions($editor_after);
if ($source_editing_is_already_enabled) {
// Nothing has been removed from the allowed source editing tags.
$this->assertFalse($source_editing_before->allowsNothing());
$this->assertTrue($source_editing_before->diff($source_editing_after)
->allowsNothing());
}
$this->assertSame($expected_source_editing_additions, $source_editing_after->diff($source_editing_before)
->toCKEditor5ElementsArray());
}
// Otherwise verify that sourceEditing configuration remains unchanged.
else {
if (!$source_editing_is_already_enabled) {
$this->assertNotContains('sourceEditing', $editor_before->getSettings()['toolbar']['items']);
}
else {
$this->assertContains('sourceEditing', $editor_before->getSettings()['toolbar']['items']);
$this->assertSame(
static::getSourceEditingRestrictions($editor_before)->toCKEditor5ElementsArray(),
static::getSourceEditingRestrictions($editor_after)->toCKEditor5ElementsArray()
);
}
}
// 3. `filter_html` restrictions MUST remain unchanged.
if ($filter_html_is_enabled) {
$filter_html_before = static::getFilterHtmlRestrictions($filter_format_before);
$filter_html_after = static::getFilterHtmlRestrictions($filter_format_after);
$this->assertTrue($filter_html_before->diff($filter_html_after)->allowsNothing());
$this->assertTrue($filter_html_after->diff($filter_html_before)->allowsNothing());
}
// 4. After: text format and editor still form a valid pair.
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
// @todo Fix stream wrappers not being available early enough in
// https://www.drupal.org/project/drupal/issues/3416735. Then remove the
// array_filter().
// @see \Drupal\Core\Config\Schema\SchemaCheckTrait::$ignoredPropertyPaths
array_filter(
iterator_to_array(CKEditor5::validatePair($editor_after, $filter_format_after)),
fn(ConstraintViolation $v) => $v->getMessage() != 'The file storage you selected is not a visible, readable and writable stream wrapper. Possible choices: <em class="placeholder"></em>.',
)
));
}
/**
* Data provider for ::test().
*
* @return array
* The test cases.
*/
public static function provider(): array {
// There are 3 aspects that need to be verified, each can be true or false,
// making for 8 test cases in total.
$test_cases = [];
foreach ([TRUE, FALSE] as $filter_html_is_enabled) {
$test_case_label_part_one = sprintf("filter_html %s", $filter_html_is_enabled ? 'enabled' : 'disabled');
foreach ([TRUE, FALSE] as $image_uploads_enabled) {
$test_case_label_part_two = sprintf("image uploads %s", $image_uploads_enabled ? 'enabled' : 'disabled');
foreach ([TRUE, FALSE] as $source_editing_already_enabled) {
$test_case_label_part_three = sprintf("sourceEditing initially %s", $source_editing_already_enabled ? 'enabled' : 'disabled');
// Generate the test case.
$label = implode(', ', [$test_case_label_part_one, $test_case_label_part_two, $test_case_label_part_three]);
$test_cases[$label] = [
'filter_html' => $filter_html_is_enabled,
'image uploads' => $image_uploads_enabled,
'sourceEditing already enabled' => $source_editing_already_enabled,
'expected sourceEditing additions' => $image_uploads_enabled ? [] : ['<img data-entity-uuid data-entity-type>'],
];
}
}
}
return $test_cases;
}
/**
* Gets the configured HTML restrictions for the Source Editing plugin.
*
* @param \Drupal\editor\EditorInterface $editor
* Text editor configured to use CKEditor 5, with Source Editing enabled.
*
* @return \Drupal\ckeditor5\HTMLRestrictions
* The configured HTML restrictions.
*/
private static function getSourceEditingRestrictions(EditorInterface $editor): HTMLRestrictions {
$settings = $editor->getSettings();
$source_editing_allowed_tags = $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'];
return HTMLRestrictions::fromString(implode(' ', $source_editing_allowed_tags));
}
/**
* Gets the configured restrictions for the `filter_html` filter plugin.
*
* @param \Drupal\filter\FilterFormatInterface $format
* Text format configured to use `filter_html`.
*
* @return \Drupal\ckeditor5\HTMLRestrictions
* The configured HTML restrictions.
*/
private static function getFilterHtmlRestrictions(FilterFormatInterface $format): HTMLRestrictions {
$allowed_html = $format
->filters('filter_html')
->getConfiguration()['settings']['allowed_html'];
return HTMLRestrictions::fromString($allowed_html);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional\Update;
use Drupal\editor\Entity\Editor;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
// cspell:ignore multiblock
/**
* @covers ckeditor5_post_update_list_multiblock
* @group Update
* @group ckeditor5
*/
class CKEditor5UpdateListMultiBlockTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.filled.standard.php.gz',
];
}
/**
* Tests that sites get the new `multiBlock` setting added.
*/
public function test(): void {
$before = Editor::loadMultiple();
$this->assertSame([
'basic_html',
'full_html',
'test_text_format',
], array_keys($before));
// Basic HTML before: settings exist for `ckeditor5_list`.
$settings = $before['basic_html']->getSettings();
$this->assertSame(['reversed', 'startIndex'], array_keys($settings['plugins']['ckeditor5_list']));
// test_text_format before: not using the List plugin.
$settings = $before['test_text_format']->getSettings();
$this->assertArrayNotHasKey('ckeditor5_list', $settings['plugins']);
$this->runUpdates();
$after = Editor::loadMultiple();
// Basic HTML after: existing settings moved under a new "properties" key,
// and the new "multiBlock" key is set to TRUE.
$this->assertNotSame($before['basic_html']->getSettings(), $after['basic_html']->getSettings());
$settings = $after['basic_html']->getSettings();
$this->assertSame(['properties', 'multiBlock'], array_keys($settings['plugins']['ckeditor5_list']));
$this->assertSame($before['basic_html']->getSettings()['plugins']['ckeditor5_list'], $settings['plugins']['ckeditor5_list']['properties']);
$this->assertTrue($settings['plugins']['ckeditor5_list']['multiBlock']);
// test_text_format after: no changes.
$this->assertSame($before['test_text_format']->getSettings(), $after['test_text_format']->getSettings());
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional\Update;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\editor\Entity\Editor;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* @covers ckeditor5_post_update_list_start_reversed
* @group Update
* @group ckeditor5
*/
class CKEditor5UpdateOlStartReversed extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.filled.standard.php.gz',
__DIR__ . '/../../../fixtures/update/ckeditor5-3396628.php',
];
}
/**
* Test that sites with <ol start> or <ol reversed> opt in to the expanded UI.
*/
public function testUpdate(): void {
$before = Editor::loadMultiple();
$this->assertSame([
'basic_html',
'full_html',
'test_format_list_ol_start',
'test_format_list_ol_start_post_3261599',
'test_text_format',
], array_keys($before));
// Basic HTML before: only <ol type> editable via Source Editing … but just
// like a real site, this update path was added too late, and many sites
// have in the meantime edited their text editor configuration through the
// UI, in which case they may already have set it. That is also the case for
// the test fixture used by update path tests.
$settings = $before['basic_html']->getSettings();
$this->assertArrayHasKey('ckeditor5_list', $settings['plugins']);
$this->assertSame(['reversed' => FALSE, 'startIndex' => TRUE], $settings['plugins']['ckeditor5_list']);
$source_editable = HTMLRestrictions::fromString(implode(' ', $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']));
$this->assertSame(['type' => TRUE], $source_editable->getAllowedElements()['ol']);
// Full HTML before: nothing listed for Source Editing.
$settings = $before['full_html']->getSettings();
$this->assertArrayHasKey('ckeditor5_list', $settings['plugins']);
$this->assertSame([], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
// test_format_list_ol_start before: <ol start foo> using Source Editing.
$settings = $before['test_format_list_ol_start']->getSettings();
$this->assertArrayNotHasKey('ckeditor5_list', $settings['plugins']);
$this->assertSame(['<ol start foo>'], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
// test_format_list_ol_start_post_3261599 before: <ol foo> for Source
// Editing.
$settings = $before['test_format_list_ol_start_post_3261599']->getSettings();
$this->assertSame(['reversed' => FALSE, 'startIndex' => TRUE], $settings['plugins']['ckeditor5_list']['properties']);
$this->assertSame(['<ol foo>'], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
// test_text_format before: not using the List plugin.
$settings = $before['test_text_format']->getSettings();
$this->assertArrayNotHasKey('ckeditor5_list', $settings['plugins']);
$this->runUpdates();
$after = Editor::loadMultiple();
// Basic HTML after: reversed=FALSE, startIndex=FALSE, Source Editing
// configuration unchanged.
$settings = $after['basic_html']->getSettings();
$this->assertSame(['reversed' => FALSE, 'startIndex' => TRUE], $settings['plugins']['ckeditor5_list']['properties']);
$source_editable = HTMLRestrictions::fromString(implode(' ', $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']));
$this->assertSame(['type' => TRUE], $source_editable->getAllowedElements()['ol']);
// Full HTML after: reversed=TRUE, startIndex=TRUE, and Source Editing
// configuration is unchanged.
$settings = $after['full_html']->getSettings();
$this->assertNotSame($before['full_html']->getSettings(), $after['full_html']->getSettings());
$this->assertSame(['reversed' => TRUE, 'startIndex' => TRUE], $settings['plugins']['ckeditor5_list']['properties']);
$this->assertSame([], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
// test_format_list_ol_start after: reversed=FALSE, startIndex=TRUE, and
// Source Editing configuration has been updated to only <ol foo>.
// Unlike the basic_html editor, this one was not yet modified by the user
// on the site, so it does not yet have `settings.plugins.ckeditor5_list`.
// Hence the missing update path is applied.
$this->assertNotSame($before['test_format_list_ol_start']->getSettings(), $after['test_format_list_ol_start']->getSettings());
$settings = $after['test_format_list_ol_start']->getSettings();
$this->assertSame(['reversed' => FALSE, 'startIndex' => TRUE], $settings['plugins']['ckeditor5_list']['properties']);
$this->assertSame(['<ol foo>'], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
// test_format_list_ol_start_post_3261599 after: no changes, because it was
// updated from CKEditor 4 post-#3261599, which made this update a no-op.
$this->assertSame($before['test_format_list_ol_start_post_3261599']->getSettings(), $after['test_format_list_ol_start_post_3261599']->getSettings());
// test_text_format after: no changes.
$this->assertSame($before['test_text_format']->getSettings(), $after['test_text_format']->getSettings());
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional\Update;
use Drupal\editor\Entity\Editor;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* @covers ckeditor5_post_update_plugins_settings_export_order
* @group Update
* @group ckeditor5
*/
class CKEditor5UpdatePluginSettingsSortTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.filled.standard.php.gz',
];
}
/**
* Ensure settings for CKEditor 5 plugins are sorted by plugin key.
*/
public function testUpdatePluginSettingsSortPostUpdate(): void {
$editor = Editor::load('basic_html');
$settings = $editor->getSettings();
$plugin_settings_before = array_keys($settings['plugins']);
$this->runUpdates();
$editor = Editor::load('basic_html');
$settings = $editor->getSettings();
$plugin_settings_after = array_keys($settings['plugins']);
// Different sort before and after, but the same values.
$this->assertNotSame($plugin_settings_before, $plugin_settings_after);
sort($plugin_settings_before);
$this->assertSame($plugin_settings_before, $plugin_settings_after);
}
/**
* Ensure settings for CKEditor 5 plugins are sorted by plugin key.
*/
public function testUpdatePluginSettingsSortEntitySave(): void {
$editor = Editor::load('basic_html');
$settings = $editor->getSettings();
$plugin_settings_before = array_keys($settings['plugins']);
$editor->save();
$editor = Editor::load('basic_html');
$settings = $editor->getSettings();
$plugin_settings_after = array_keys($settings['plugins']);
// Different sort before and after, but the same values.
$this->assertNotSame($plugin_settings_before, $plugin_settings_after);
sort($plugin_settings_before);
$this->assertSame($plugin_settings_before, $plugin_settings_after);
}
}

View File

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
// cspell:ignore sourceediting
/**
* Tests for CKEditor 5 in the admin UI.
*
* @group ckeditor5
* @internal
*/
class AdminUiTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'media_library',
'editor_test',
'ckeditor5_incompatible_filter_test',
];
/**
* Confirm settings only trigger AJAX when select value is CKEditor 5.
*/
public function testSettingsOnlyFireAjaxWithCkeditor5(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat($page, $assert_session);
$this->addNewTextFormat($page, $assert_session, 'unicorn');
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
// Enable media embed to trigger an AJAX rebuild.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$this->assertNoAjaxRequestTriggered();
$page->checkField('filters[media_embed][status]');
$assert_session->assertExpectedAjaxRequest(1);
// Perform the same steps as above with CKEditor, and confirm AJAX callbacks
// are not triggered on settings changes.
$this->drupalGet('admin/config/content/formats/manage/unicorn');
// Enable media embed to confirm a format not using CKEditor 5 will not
// trigger an AJAX rebuild.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$page->checkField('filters[media_embed][status]');
$this->assertNoAjaxRequestTriggered();
// Confirm that AJAX updates happen when attempting to switch to CKEditor 5,
// even if prevented from doing so by validation.
$this->drupalGet('admin/config/content/formats/add');
$this->assertFalse($assert_session->elementExists('css', '#edit-name-machine-name-suffix')->isVisible());
$name_field = $page->findField('name');
$name_field->setValue('trigger validator');
$this->assertTrue($assert_session->elementExists('css', '#edit-name-machine-name-suffix')->isVisible());
// Enable a filter that is incompatible with CKEditor 5, so validation is
// triggered when attempting to switch.
$incompatible_filter_name = 'filters[filter_incompatible][status]';
$this->assertTrue($page->hasUncheckedField($incompatible_filter_name));
$page->checkField($incompatible_filter_name);
$this->assertNoAjaxRequestTriggered();
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertExpectedAjaxRequest(1);
$filter_warning = 'CKEditor 5 only works with HTML-based text formats. The "A TYPE_MARKUP_LANGUAGE filter incompatible with CKEditor 5" (filter_incompatible) filter implies this text format is not HTML anymore.';
// The presence of this validation error message confirms the AJAX callback
// was invoked.
$assert_session->pageTextContains($filter_warning);
// Disable the incompatible filter. This should trigger another AJAX rebuild
// which will include the removal of the validation error as the issue has
// been corrected.
$this->assertTrue($page->hasCheckedField($incompatible_filter_name));
$page->uncheckField($incompatible_filter_name);
$assert_session->assertExpectedAjaxRequest(2);
$assert_session->pageTextNotContains($filter_warning);
}
/**
* Asserts that no (new) AJAX requests were triggered.
*
* @param int $expected_cumulative_ajax_request_count
* The number of expected observed XHR requests since the page was loaded.
*/
protected function assertNoAjaxRequestTriggered(int $expected_cumulative_ajax_request_count = 0): void {
// In case of no requests triggered at all yet.
if ($expected_cumulative_ajax_request_count === 0) {
$result = $this->getSession()->evaluateScript(<<<JS
(function() {
return window.drupalCumulativeXhrCount;
}())
JS);
$this->assertSame(0, $result);
}
else {
// In case of the non-first AJAX request, ensure that no AJAX requests are
// in progress.
try {
$this->assertSession()->assertWaitOnAjaxRequest(500);
}
catch (\RuntimeException $e) {
throw new \LogicException(sprintf('This call to %s claims there no AJAX request was triggered, but this is wrong: %s.', __METHOD__, $e->getMessage()));
}
catch (\LogicException $e) {
// This is the intent: ::assertWaitOnAjaxRequest() should detect an
// "incorrect" call, because this assertion is asserting *no* AJAX
// requests have been triggered.
assert(str_contains($e->getMessage(), 'Unnecessary'));
$result = $this->getSession()->evaluateScript(<<<JS
(function() {
return window.drupalCumulativeXhrCount;
}())
JS);
$this->assertSame($expected_cumulative_ajax_request_count, $result);
}
}
// Now that there definitely is no more AJAX request in progress, count the
// number of actual XHR requests, ensure they match.
$javascript = <<<JS
(function(){
return window.performance
.getEntries()
.filter(entry => entry.initiatorType === 'xmlhttprequest')
.length
})()
JS;
$this->assertSame($expected_cumulative_ajax_request_count, $this->getSession()->evaluateScript($javascript));
}
/**
* CKEditor 5's filter UI modifications should not break it for other editors.
*/
public function testUnavailableFiltersHiddenWhenSwitching(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session, 'unicorn');
$assert_session->pageTextNotContains('Filter settings');
// Switching to CKEditor 5 should keep the filter settings hidden.
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
$assert_session->pageTextNotContains('Filter settings');
}
/**
* Test that filter settings are only visible when the filter is enabled.
*/
public function testFilterCheckboxesToggleSettings(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
$media_tab = $page->find('css', '[href^="#edit-filters-media-embed-settings"]');
$this->assertFalse($media_tab->isVisible(), 'Media filter settings should not be present because media filter is not enabled');
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$page->checkField('filters[media_embed][status]');
$assert_session->assertWaitOnAjaxRequest();
$media_tab = $assert_session->waitForElementVisible('css', '[href^="#edit-filters-media-embed-settings"]');
$this->assertTrue($media_tab->isVisible(), 'Media settings should appear when media filter enabled');
$page->uncheckField('filters[media_embed][status]');
$assert_session->assertWaitOnAjaxRequest();
$media_tab = $page->find('css', '[href^="#edit-filters-media-embed-settings"]');
$this->assertFalse($media_tab->isVisible(), 'Media settings should be removed when media filter disabled');
}
/**
* Tests that image upload settings (stored out of band) are validated too.
*/
public function testImageUploadSettingsAreValidated(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat($page, $assert_session);
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
// Add the image plugin to the CKEditor 5 toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalInsertImage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertExpectedAjaxRequest(1);
// Open the vertical tab with its settings.
$page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-image"]')->click();
$this->assertTrue($assert_session->waitForText('Enable image uploads'));
// Check the "Enable image uploads" checkbox.
$assert_session->checkboxNotChecked('editor[settings][plugins][ckeditor5_image][status]');
$page->checkField('editor[settings][plugins][ckeditor5_image][status]');
$assert_session->assertExpectedAjaxRequest(2);
// Enter a nonsensical maximum file size.
$page->fillField('editor[settings][plugins][ckeditor5_image][max_size]', 'foobar');
$this->assertNoRealtimeValidationErrors();
// Enable another toolbar item to trigger validation.
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertExpectedAjaxRequest(3);
// The expected validation error must be present.
$assert_session->elementExists('css', '[role=alert]:contains("This value must be a number of bytes, optionally with a unit such as "MB" or "megabytes".")');
// Enter no maximum file size because it is optional, this should result in
// no validation error and it being set to `null`.
$page->findField('editor[settings][plugins][ckeditor5_image][max_size]')->setValue('');
// Remove a toolbar item to trigger validation.
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowUp');
$assert_session->assertExpectedAjaxRequest(4);
// No more validation errors, let's save.
$this->assertNoRealtimeValidationErrors();
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The text format ckeditor5 has been updated');
}
/**
* Ensure CKEditor 5 admin UI's real-time validation errors do not accumulate.
*/
public function testMessagesDoNotAccumulate(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat($page, $assert_session);
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
// Add the source editing plugin to the CKEditor 5 toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$find_validation_error_messages = function () use ($page): array {
return $page->findAll('css', '[role=alert]:contains("The following tag(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: Bold (<strong>).")');
};
// No validation errors when we start.
$this->assertCount(0, $find_validation_error_messages());
// Configure Source Editing to allow editing `<strong>` to trigger
// validation error.
$assert_session->waitForText('Source editing');
$page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->click();
$assert_session->waitForText('Manually editable HTML tags');
$source_edit_tags_field = $assert_session->fieldExists('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]');
$source_edit_tags_field->setValue('<strong>');
$assert_session->assertWaitOnAjaxRequest();
$this->assertCount(1, $find_validation_error_messages());
// Revert Source Editing it: validation messages should be gone.
$source_edit_tags_field->setValue('');
$assert_session->assertWaitOnAjaxRequest();
$this->assertCount(0, $find_validation_error_messages());
// Add `<strong>` again: validation messages should be back.
$source_edit_tags_field->setValue('<strong>');
$assert_session->assertWaitOnAjaxRequest();
$this->assertCount(1, $find_validation_error_messages());
}
/**
* Tests the plugin settings form section.
*/
public function testPluginSettingsFormSection(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// The default toolbar only enables the configurable heading plugin and the
// non-configurable bold and italic plugins.
$assert_session->fieldValueEquals('editor[settings][toolbar][items]', '["heading","bold","italic"]');
// The heading plugin config form should be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-heading"]');
// Remove the heading plugin from the toolbar.
$this->triggerKeyUp('.ckeditor5-toolbar-item-heading', 'ArrowUp');
$assert_session->assertWaitOnAjaxRequest();
// The heading plugin config form should no longer be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-heading"]');
// The plugin settings wrapper should still be present, but empty.
$assert_session->elementExists('css', '#plugin-settings-wrapper');
$assert_session->elementNotContains('css', '#plugin-settings-wrapper', '<div');
// Enable the source plugin.
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The source plugin config form should be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting"]');
// The filter-dependent configurable plugin should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-media-media"]');
// Enable the filter that the configurable plugin depends on.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$page->checkField('filters[media_embed][status]');
$assert_session->assertWaitOnAjaxRequest();
// The filter-dependent configurable plugin should be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-media-media"]');
}
/**
* Tests the language config form.
*/
public function testLanguageConfigForm(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// The language plugin config form should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-language"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-textPartLanguage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-textPartLanguage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The CKEditor 5 module should warn that `<span>` cannot be created.
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="warning"]:contains("The Language plugin needs another plugin to create <span>, for it to be able to create the following attributes: <span lang dir>. Enable a plugin that supports creating this tag. If none exists, you can configure the Source Editing plugin to support it.")');
// Make `<span>` creatable.
$this->assertNotEmpty($assert_session->elementExists('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and should
// have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<span>';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
// Dispatching an `input` event does not work in WebDriver. Enabling another
// toolbar item which has no associated HTML elements forces it.
$this->triggerKeyUp('.ckeditor5-toolbar-item-undo', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// Confirm there are no longer any warnings.
$assert_session->waitForElementRemoved('css', '[data-drupal-messages] [role="alert"]');
// The language plugin config form should now be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-language"]');
// It must also be possible to remove the language plugin again.
$this->triggerKeyUp('.ckeditor5-toolbar-item-textPartLanguage', 'ArrowUp');
$assert_session->assertWaitOnAjaxRequest();
// The language plugin config form should not be present anymore.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-language"]');
}
}

View File

@@ -0,0 +1,502 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Core\Entity\Entity\EntityViewMode;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Symfony\Component\Yaml\Yaml;
// cspell:ignore esque imageUpload sourceediting Editing's
/**
* Tests for CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class CKEditor5AllowedTagsTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'editor_test',
'ckeditor5',
'media',
'media_library',
'ckeditor5_incompatible_filter_test',
];
/**
* The default CKEditor 5 allowed elements.
*
* @var string
*/
protected $allowedElements = '<br> <p> <h2> <h3> <h4> <h5> <h6> <strong> <em>';
/**
* The default allowed elements for filter_html's "allowed_html" setting.
*
* @see \Drupal\filter\Plugin\Filter\FilterHtml
*
* @var string
*/
protected $defaultElementsWhenUpdatingNotCkeditor5 = "<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type='1 A I'> <li> <dl> <dt> <dd> <h2 id='jump-*'> <h3 id> <h4 id> <h5 id> <h6 id>";
/**
* The expected allowed elements after updating to CKEditor 5.
*
* @var string
*/
protected $defaultElementsAfterUpdatingToCkeditor5 = '<br> <p> <h2 id="jump-*"> <h3 id> <h4 id> <h5 id> <h6 id> <cite> <dl> <dt> <dd> <a hreflang href> <blockquote cite> <ul type> <ol type="1 A I" start> <strong> <em> <code> <li>';
/**
* Test enabling CKEditor 5 in a way that triggers validation.
*/
public function testEnablingToVersion5Validation(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$incompatible_filter_name = 'filters[filter_incompatible][status]';
$filter_warning = 'CKEditor 5 only works with HTML-based text formats. The "A TYPE_MARKUP_LANGUAGE filter incompatible with CKEditor 5" (filter_incompatible) filter implies this text format is not HTML anymore.';
$this->createNewTextFormat($page, $assert_session, 'unicorn');
$page->checkField('filters[filter_html][status]');
$page->checkField($incompatible_filter_name);
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertExpectedAjaxRequest(2);
$assert_session->pageTextContains($filter_warning);
// Disable the incompatible filter.
$page->uncheckField($incompatible_filter_name);
// Confirm there are no longer any warnings.
$assert_session->waitForElementRemoved('css', '[data-drupal-messages] [role="alert"]');
// Confirm the text format can be saved.
$this->saveNewTextFormat($page, $assert_session);
}
/**
* Tests that when image uploads were enabled, they remain enabled.
*/
public function testImageUploadsRemainEnabled(): void {
FilterFormat::create([
'format' => 'editor_with_image_uploads',
'name' => 'Text Editor with image uploads enabled',
])->save();
Editor::create([
'format' => 'editor_with_image_uploads',
'editor' => 'unicorn',
'image_upload' => [
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => 0,
'height' => 0,
],
],
])->save();
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Assert that image uploads are enabled initially.
$this->drupalGet('admin/config/content/formats/manage/editor_with_image_uploads');
$this->assertTrue($page->hasCheckedField('Enable image uploads'));
// Switch the text format to CKEditor 5.
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
// Enable the image toolbar item. This does NOT enable image uploads: it
// triggers the image upload settings form to become visible, to allow the
// image upload status to be checked.
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// Assert that image uploads are still enabled.
$this->assertTrue($page->hasCheckedField('Enable image uploads'));
}
/**
* Confirm that switching to CKEditor 5 from another editor updates tags.
*/
public function testSwitchToVersion5(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session, 'unicorn');
// Enable the HTML filter.
$this->assertTrue($page->hasUncheckedField('filters[filter_html][status]'));
$page->checkField('filters[filter_html][status]');
// Confirm the allowed HTML tags are the defaults initially.
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $this->defaultElementsWhenUpdatingNotCkeditor5);
$this->saveNewTextFormat($page, $assert_session);
$assert_session->pageTextContains('Added text format unicorn');
// Return to the config form to confirm that switching text editors on
// existing formats will properly switch allowed tags.
$this->drupalGet('admin/config/content/formats/manage/unicorn');
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $this->defaultElementsWhenUpdatingNotCkeditor5);
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
$assert_session->pageTextContains('The <br>, <p> tags were added because they are required by CKEditor 5');
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $this->defaultElementsAfterUpdatingToCkeditor5);
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The text format unicorn has been updated');
}
/**
* Tests that the img tag is added after enabling image uploads.
*/
public function testImgAddedViaUploadPlugin(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
// Allowed tags are currently the default, with no <img>.
$this->assertEquals($this->allowedElements, $allowed_html_field->getValue());
// The image upload settings form should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-imageupload"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalInsertImage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The image upload settings form should now be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-active .ckeditor5-toolbar-item-drupalInsertImage'));
// The image insert plugin is enabled and inserting <img> is allowed.
$this->assertEquals($this->allowedElements . ' <img src alt height width>', $allowed_html_field->getValue());
$page->clickLink('Image');
$assert_session->waitForText('Enable image uploads');
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][ckeditor5_image][status]'));
$page->checkField('editor[settings][plugins][ckeditor5_image][status]');
$assert_session->assertWaitOnAjaxRequest();
// Enabling image uploads adds <img> with several attributes to allowed
// tags.
$this->assertEquals($this->allowedElements . ' <img src alt height width data-entity-uuid data-entity-type>', $allowed_html_field->getValue());
// Also enabling the caption filter will add the data-caption attribute to
// <img>.
$this->assertTrue($page->hasUncheckedField('filters[filter_caption][status]'));
$page->checkField('filters[filter_caption][status]');
$assert_session->assertWaitOnAjaxRequest();
$this->assertEquals($this->allowedElements . ' <img src alt height width data-entity-uuid data-entity-type data-caption>', $allowed_html_field->getValue());
// Also enabling the alignment filter will add the data-align attribute to
// <img>.
$this->assertTrue($page->hasUncheckedField('filters[filter_align][status]'));
$page->checkField('filters[filter_align][status]');
$assert_session->assertWaitOnAjaxRequest();
$this->assertEquals($this->allowedElements . ' <img src alt height width data-entity-uuid data-entity-type data-caption data-align>', $allowed_html_field->getValue());
// Disable image upload.
$page->clickLink('Image');
$assert_session->waitForText('Enable image uploads');
$this->assertTrue($page->hasCheckedField('editor[settings][plugins][ckeditor5_image][status]'));
$page->uncheckField('editor[settings][plugins][ckeditor5_image][status]');
$assert_session->assertWaitOnAjaxRequest();
// The image insert is still allowed when image uploads are disabled.
$this->assertEquals($this->allowedElements . ' <img src alt height width data-caption data-align>', $allowed_html_field->getValue());
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalInsertImage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowUp');
$assert_session->assertWaitOnAjaxRequest();
// Confirm <img> is no longer an allowed tag, once image insert is disabled.
$this->assertEquals($this->allowedElements, $allowed_html_field->getValue());
}
/**
* Test filter_html allowed tags.
*/
public function testAllowedTags(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// Confirm the "allowed tags" field is read only, and the value
// matches the tags required by CKEditor.
// Allowed HTML field is readonly and its wrapper has a form-disabled class.
$this->assertNotEmpty($assert_session->waitForElement('css', '.js-form-item-filters-filter-html-settings-allowed-html.form-disabled'));
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
$this->assertSame($this->allowedElements, $allowed_html_field->getValue());
$this->saveNewTextFormat($page, $assert_session);
$assert_session->pageTextContains('Added text format ckeditor5');
$assert_session->pageTextContains('Text formats and editors');
// Confirm the filter config was updated with the correct allowed tags.
$this->assertSame($this->allowedElements, FilterFormat::load('ckeditor5')->filters('filter_html')->getConfiguration()['settings']['allowed_html']);
$page->find('css', '[data-drupal-selector="edit-formats-ckeditor5"]')->clickLink('Configure');
// Add the block quote plugin to the CKEditor 5 toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-blockQuote'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-blockQuote', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$allowed_with_blockquote = $this->allowedElements . ' <blockquote>';
$assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_blockquote);
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The text format ckeditor5 has been updated.');
// Flush caches so the updated config can be checked.
drupal_flush_all_caches();
// Confirm that the tags required by the newly-added plugins were correctly
// saved.
$this->assertSame($allowed_with_blockquote, FilterFormat::load('ckeditor5')->filters('filter_html')->getConfiguration()['settings']['allowed_html']);
$page->find('css', '[data-drupal-selector="edit-formats-ckeditor5"]')->clickLink('Configure');
// And for good measure, confirm the correct tags are in the form field when
// returning to the form.
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_blockquote);
// Add the source editing plugin to the CKEditor 5 toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-available .ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// Updating Source Editing's editable tags should automatically update
// filter_html to include those additional tags.
$assert_session->waitForText('Source editing');
$page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->click();
$assert_session->waitForText('Manually editable HTML tags');
$source_edit_tags_field = $assert_session->fieldExists('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]');
$source_edit_tags_field->setValue('<aside>');
$assert_session->assertWaitOnAjaxRequest();
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', '<br> <p> <h2> <h3> <h4> <h5> <h6> <aside> <strong> <em> <blockquote>');
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
// Adding tags to Source Editing's editable tags that are already supported
// by enabled CKEditor 5 plugins must trigger a validation error, and that
// error must be associated with the correct form item.
$source_edit_tags_field->setValue('<aside><strong>');
$assert_session->waitForText('The following tag(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: Bold (<strong>)');
$this->assertTrue($page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->getParent()->hasClass('is-selected'));
$this->assertSame('true', $page->findField('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]')->getAttribute('aria-invalid'));
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
// The same validation error appears when saving the form regardless of the
// immediate AJAX validation error above.
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The following tag(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: Bold (<strong>)');
$this->assertTrue($page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->getParent()->hasClass('is-selected'));
$this->assertSame('true', $page->findField('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]')->getAttribute('aria-invalid'));
$assert_session->pageTextNotContains('The text format ckeditor5 has been updated');
// Wait for the "Source editing" vertical tab to appear, remove the already
// supported tags and re-save. Now the text format should save successfully.
$assert_session->waitForText('Source editing');
$page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->click();
$assert_session->pageTextContains('Manually editable HTML tags');
$source_edit_tags_field = $assert_session->fieldExists('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]');
$source_edit_tags_field->setValue('<aside>');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The text format ckeditor5 has been updated');
$assert_session->pageTextNotContains('The following tag(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: Bold (<strong>)');
// Ensure that CKEditor can be initialized with Source Editing.
// @see https://www.drupal.org/i/3231427
$this->drupalGet('node/add');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
}
/**
* Test that <drupal-media> is added to allowed tags when media embed enabled.
*/
public function testMediaElementAllowedTags(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
EntityViewMode::create([
'id' => 'media.view_mode_1',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 1',
])->save();
EntityViewMode::create([
'id' => 'media.view_mode_2',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 2',
])->save();
$this->createNewTextFormat($page, $assert_session);
// Allowed HTML field is readonly and its wrapper has a form-disabled class.
$this->assertNotEmpty($assert_session->waitForElement('css', '.js-form-item-filters-filter-html-settings-allowed-html.form-disabled'));
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
// Allowed tags are currently the default, with no <drupal-media>.
$this->assertEquals($this->allowedElements, $allowed_html_field->getValue());
// Enable media embed.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$this->assertNull($assert_session->waitForElementVisible('css', '[data-drupal-selector=edit-filters-media-embed-settings]', 0));
$page->checkField('filters[media_embed][status]');
$assert_session->assertExpectedAjaxRequest(2);
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector=edit-filters-media-embed-settings]', 0));
$page->clickLink('Embed media');
$page->checkField('filters[media_embed][settings][allowed_view_modes][view_mode_1]');
$page->checkField('filters[media_embed][settings][allowed_view_modes][view_mode_2]');
$allowed_with_media = $this->allowedElements . ' <drupal-media data-entity-type data-entity-uuid alt data-view-mode>';
$allowed_with_media_without_view_mode = $this->allowedElements . ' <drupal-media data-entity-type data-entity-uuid alt>';
$page->clickLink('Media');
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][media_media][allow_view_mode_override]'));
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_media_without_view_mode);
$page->checkField('editor[settings][plugins][media_media][allow_view_mode_override]');
$assert_session->assertExpectedAjaxRequest(3);
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_media);
$this->saveNewTextFormat($page, $assert_session);
$assert_session->pageTextContains('Added text format ckeditor5.');
// Confirm <drupal-media> was added to allowed tags on save, as a result of
// enabling the media embed filter.
$this->assertSame($allowed_with_media, FilterFormat::load('ckeditor5')->filters('filter_html')->getConfiguration()['settings']['allowed_html']);
$page->find('css', '[data-drupal-selector="edit-formats-ckeditor5"]')->clickLink('Configure');
// Confirm that <drupal-media> is now included in the "Allowed tags" form
// field.
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_media);
// Ensure that data-align attribute is added to <drupal-media> when
// filter_align is enabled.
$page->checkField('filters[filter_align][status]');
$assert_session->assertExpectedAjaxRequest(1);
$this->assertEquals($this->allowedElements . ' <drupal-media data-entity-type data-entity-uuid alt data-view-mode data-align>', $allowed_html_field->getValue());
// Disable media embed.
$this->assertTrue($page->hasCheckedField('filters[media_embed][status]'));
$page->uncheckField('filters[media_embed][status]');
$assert_session->assertExpectedAjaxRequest(2);
// Confirm allowed tags no longer has <drupal-media>.
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $this->allowedElements);
}
/**
* Tests full HTML text format.
*/
public function testFullHtml(): void {
FilterFormat::create(
Yaml::parseFile('core/profiles/standard/config/install/filter.format.full_html.yml')
)->save();
FilterFormat::create(
Yaml::parseFile('core/profiles/standard/config/install/filter.format.basic_html.yml')
)->save();
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Add a node with text rendered via the Plain Text format.
$this->drupalGet('node/add');
$page->fillField('title[0][value]', 'My test content');
$page->fillField('body[0][value]', '<foo bar="baz">⬅️✌️➡️</foo><p><a style="color:#ff0000;" foo="bar" hreflang="en" href="https://example.com"><abbr title="National Aeronautics and Space Administration">NASA</abbr> is an acronym.</a></p>');
$page->pressButton('Save');
// Configure Full HTML text format to use CKEditor 5.
$this->drupalGet('admin/config/content/formats/manage/full_html');
$page->checkField('roles[authenticated]');
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save configuration');
$this->assertTrue($assert_session->waitForText('The text format Full HTML has been updated.'));
// Change the node's text format to Full HTML.
$this->drupalGet('node/1/edit');
$filter_tips = $page->find('css', '[data-drupal-format-id="basic_html"]');
$this->assertTrue($filter_tips->isVisible());
$page->selectFieldOption('body[0][format]', 'full_html');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
// Check the visibility of "Filter tips" by clicking the "Cancel" button.
$page->pressButton('Cancel');
$this->assertTrue($filter_tips->isVisible());
$page->selectFieldOption('body[0][format]', 'full_html');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
$page->pressButton('Continue');
// Ensure the editor is loaded and ensure that arbitrary markup is retained.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$page->pressButton('Save');
// But note that the `style` attribute was stripped by
// \Drupal\editor\EditorXssFilter\Standard.
$assert_session->responseContains('<foo bar="baz">⬅️✌️➡️</foo><p><a foo="bar" hreflang="en" href="https://example.com"><abbr title="National Aeronautics and Space Administration">NASA</abbr> is an acronym.</a></p>');
// Ensure attributes are retained after enabling link plugin.
$this->drupalGet('admin/config/content/formats/manage/full_html');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-link'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-link', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save configuration');
$this->drupalGet('node/1/edit');
$page->pressButton('Save');
$assert_session->responseContains('<p><a foo="bar" hreflang="en" href="https://example.com"><abbr title="National Aeronautics and Space Administration">NASA</abbr> is an acronym.</a></p>');
// Configure Basic HTML text format to use CKE5 and enable the link plugin.
$this->drupalGet('admin/config/content/formats/manage/basic_html');
$page->checkField('roles[authenticated]');
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-available .ckeditor5-toolbar-item-underline'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-underline', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save configuration');
$this->assertTrue($assert_session->waitForText('The text format Basic HTML has been updated.'));
// Change the node's text format to Basic HTML.
$this->drupalGet('node/1/edit');
$page->selectFieldOption('body[0][format]', 'basic_html');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
$page->pressButton('Continue');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save');
// The `style` and foo` attributes should have been removed, as should the
// `<abbr>` and `<foo>` tags.
$assert_session->responseContains('<p>⬅️✌️➡️</p><p><a href="https://example.com" hreflang="en">NASA is an acronym.</a></p>');
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\user\RoleInterface;
use Symfony\Component\Validator\ConstraintViolation;
/**
* Tests for CKEditor 5 to ensure correct focus management in dialogs.
*
* @group ckeditor5
* @internal
*/
class CKEditor5DialogTest extends CKEditor5TestBase {
use CKEditor5TestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
'ckeditor5_test',
];
/**
* Tests if CKEditor 5 tooltips can be interacted with in dialogs.
*/
public function testCKEditor5FocusInTooltipsInDialog(): void {
FilterFormat::create([
'format' => 'test_format',
'name' => 'CKEditor 5 with link',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'test_format',
'editor' => 'ckeditor5',
'settings' => [
'toolbar' => [
'items' => ['link'],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('/ckeditor5_test/dialog');
$page->clickLink('Add Node');
$assert_session->waitForElementVisible('css', '[role="dialog"]');
$assert_session->assertWaitOnAjaxRequest();
$content_area = $assert_session->waitForElementVisible('css', '.ck-editor__editable');
// Focus the editable area first.
$content_area->click();
// Then press the button to add a link.
$this->pressEditorButton('Link');
$link_url = '/ckeditor5_test/dialog';
$input = $assert_session->waitForElementVisible('css', '.ck-balloon-panel input.ck-input-text');
// Make sure the input field can have focus and we can type into it.
$input->setValue($link_url);
// Save the new link.
$page->find('css', '.ck-balloon-panel .ck-button-save')->click();
// Make sure something was added to the text.
$this->assertNotEmpty($content_area->getText());
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\editor\Entity\Editor;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\Entity\User;
use Drupal\user\RoleInterface;
/**
* Tests that the fragment link points to CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class CKEditor5FragmentLinkTest extends WebDriverTestBase {
use TestFileCreationTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'ckeditor5'];
/**
* The admin user.
*
* @var \Drupal\user\Entity\User
*/
protected User $account;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create a text format and associate CKEditor 5.
FilterFormat::create([
'format' => 'ckeditor5',
'name' => 'CKEditor 5 with image upload',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'ckeditor5',
'editor' => 'ckeditor5',
])->save();
// Create a node type for testing.
NodeType::create(['type' => 'page', 'name' => 'page'])->save();
$field_storage = FieldStorageConfig::loadByName('node', 'body');
// Create a body field instance for the 'page' node type.
FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
'label' => 'Body',
'settings' => ['display_summary' => TRUE],
'required' => TRUE,
])->save();
// Assign widget settings for the 'default' form mode.
EntityFormDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'page',
'mode' => 'default',
'status' => TRUE,
])->setComponent('body', ['type' => 'text_textarea_with_summary'])
->save();
$this->account = $this->drupalCreateUser([
'administer nodes',
'create page content',
]);
$this->drupalLogin($this->account);
}
/**
* Tests if the fragment link to a textarea works with CKEditor 5 enabled.
*/
public function testFragmentLink(): void {
$session = $this->getSession();
$web_assert = $this->assertSession();
$ckeditor_class = '.ck-editor';
$ckeditor_id = '#cke_edit-body-0-value';
$this->drupalGet('node/add/page');
// Add a bottom margin to the title field to be sure the body field is not
// visible.
$session->executeScript("document.getElementById('edit-title-0-value').style.marginBottom = window.innerHeight*2 +'px';");
$this->assertSession()->waitForElementVisible('css', $ckeditor_id);
// Check that the CKEditor 5-enabled body field is currently not visible in
// the viewport.
$web_assert->assertNotVisibleInViewport('css', $ckeditor_class, 'topLeft', 'CKEditor 5-enabled body field is visible.');
$before_url = $session->getCurrentUrl();
// Trigger a hash change with as target the hidden textarea.
$session->executeScript("location.hash = '#edit-body-0-value';");
// Check that the CKEditor 5-enabled body field is visible in the viewport.
// The hash change adds an ID to the CKEditor 5 instance so check its
// visibility using the ID now.
$web_assert->assertVisibleInViewport('css', $ckeditor_id, 'topLeft', 'CKEditor 5-enabled body field is not visible.');
// Use JavaScript to go back in the history instead of
// \Behat\Mink\Session::back() because that function doesn't work after a
// hash change.
$session->executeScript("history.back();");
$after_url = $session->getCurrentUrl();
// Check that going back in the history worked.
self::assertEquals($before_url, $after_url, 'History back works.');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
/**
* Tests for CKEditor 5 to ensure correct styling in off-canvas.
*
* @group ckeditor5
* @internal
*/
class CKEditor5OffCanvasTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
'ckeditor5_test',
];
/**
* Tests if CKEditor is properly styled inside an off-canvas dialog.
*/
public function testOffCanvasStyles(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$this->addNewTextFormat($page, $assert_session);
$this->drupalGet('/ckeditor5_test/off_canvas');
// The "Add Node" link triggers an off-canvas dialog with an add node form
// that includes CKEditor.
$page->clickLink('Add Node');
$assert_session->waitForElementVisible('css', '#drupal-off-canvas-wrapper');
$assert_session->assertWaitOnAjaxRequest();
$styles = $assert_session->elementExists('css', 'style#ckeditor5-off-canvas-reset');
$this->assertStringContainsString('#drupal-off-canvas-wrapper [data-drupal-ck-style-fence]', $styles->getHtml());
$assert_session->elementExists('css', '.ck');
$ckeditor_toolbar_bg_color = $this->getSession()->evaluateScript('window.getComputedStyle(document.querySelector(\'.ck.ck-toolbar\')).backgroundColor');
$this->assertEquals('rgb(255, 255, 255)', $ckeditor_toolbar_bg_color, 'Toolbar background-color should be unaffected by off-canvas');
// Editable area should be visible.
$assert_session->elementExists('css', '.ck .ck-content');
$ckeditor_editable_bg_color = $this->getSession()->evaluateScript('window.getComputedStyle(document.querySelector(\'.ck.ck-content\')).backgroundColor');
$this->assertEquals('rgb(255, 255, 255)', $ckeditor_editable_bg_color, 'Content background-color should be unaffected by off-canvas');
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
/**
* Tests read-only mode for CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class CKEditor5ReadOnlyModeTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5_read_only_mode',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$field_storage = FieldStorageConfig::create([
'field_name' => 'field_second_ckeditor5_field',
'entity_type' => 'node',
'type' => 'text_with_summary',
'cardinality' => 1,
]);
$field_storage->save();
// Attach an instance of the field to the page content type.
FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
'label' => 'Second CKEditor5 field',
])->save();
$this->container->get('entity_display.repository')
->getFormDisplay('node', 'page')
->setComponent('field_second_ckeditor5_field', [
'type' => 'text_textarea_with_summary',
])
->save();
}
/**
* Test that disabling a CKEditor 5 field results in an uneditable editor.
*/
public function testReadOnlyMode(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat($page, $assert_session);
// Check that both CKEditor 5 fields are editable.
$this->drupalGet('node/add');
$assert_session->elementAttributeContains('css', '.field--name-body .ck-editor .ck-content', 'contenteditable', 'true');
$assert_session->elementAttributeContains('css', '.field--name-field-second-ckeditor5-field .ck-editor .ck-content', 'contenteditable', 'true');
$this->container->get('state')->set('ckeditor5_read_only_mode_body_enabled', TRUE);
// Check that the first body field is no longer editable.
$this->drupalGet('node/add');
$assert_session->elementAttributeContains('css', '.field--name-body .ck-editor .ck-content', 'contenteditable', 'false');
$assert_session->elementAttributeContains('css', '.field--name-field-second-ckeditor5-field .ck-editor .ck-content', 'contenteditable', 'true');
$this->container->get('state')->set('ckeditor5_read_only_mode_second_ckeditor5_field_enabled', TRUE);
// Both fields are disabled, check that both fields are no longer editable.
$this->drupalGet('node/add');
$assert_session->elementAttributeContains('css', '.field--name-body .ck-editor .ck-content', 'contenteditable', 'false');
$assert_session->elementAttributeContains('css', '.field--name-field-second-ckeditor5-field .ck-editor .ck-content', 'contenteditable', 'false');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Behat\Mink\Element\TraversableElement;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
// cspell:ignore esque
/**
* Base class for testing CKEditor 5.
*
* @ingroup testing
* @internal
*/
abstract class CKEditor5TestBase extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page']);
$this->drupalLogin($this->drupalCreateUser([
'administer filters',
'create page content',
'edit own page content',
]));
}
/**
* Add and save a new text format using CKEditor 5.
*/
public function addNewTextFormat($page, $assert_session, $name = 'ckeditor5') {
$this->createNewTextFormat($page, $assert_session, $name);
$this->saveNewTextFormat($page, $assert_session);
}
/**
* Create a new text format using CKEditor 5.
*/
public function createNewTextFormat($page, $assert_session, $name = 'ckeditor5') {
$this->drupalGet('admin/config/content/formats/add');
$page->fillField('name', $name);
$assert_session->waitForText('Machine name');
$this->assertNotEmpty($assert_session->waitForText($name));
$page->checkField('roles[authenticated]');
if ($name === 'ckeditor5') {
// Enable the HTML filter, at least one HTML restricting filter is needed
// before CKEditor 5 can be enabled.
$this->assertTrue($page->hasUncheckedField('filters[filter_html][status]'));
$page->checkField('filters[filter_html][status]');
// Add the tags that must be included in the html filter for CKEditor 5.
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$allowed_html_field->setValue('<p> <br>');
}
$page->selectFieldOption('editor[editor]', $name);
$assert_session->assertExpectedAjaxRequest(1);
}
/**
* Save the new text format.
*/
public function saveNewTextFormat($page, $assert_session) {
$page->pressButton('Save configuration');
$this->assertTrue($assert_session->waitForText('Added text format'), "Confirm new text format saved");
}
/**
* Trigger a keyup event on the selected element.
*
* @param string $selector
* The css selector for the element.
* @param string $key
* The keyCode.
*/
protected function triggerKeyUp(string $selector, string $key) {
$script = <<<JS
(function (selector, key) {
const btn = document.querySelector(selector);
btn.dispatchEvent(new KeyboardEvent('keydown', { key }));
btn.dispatchEvent(new KeyboardEvent('keyup', { key }));
})('{$selector}', '{$key}')
JS;
$options = [
'script' => $script,
'args' => [],
];
$this->getSession()->getDriver()->getWebDriverSession()->execute($options);
}
/**
* Decorates ::fieldValueEquals() to force DrupalCI to provide useful errors.
*
* @param string $field
* Field id|name|label|value.
* @param string $value
* Field value.
* @param \Behat\Mink\Element\TraversableElement $container
* Document to check against.
*
* @throws \Behat\Mink\Exception\ExpectationException
*
* @see \Behat\Mink\WebAssert::fieldValueEquals()
*/
protected function assertHtmlEsqueFieldValueEquals($field, $value, ?TraversableElement $container = NULL) {
$assert_session = $this->assertSession();
$node = $assert_session->fieldExists($field, $container);
$actual = $node->getValue();
$regex = '/^' . preg_quote($value, '/') . '$/ui';
$message = sprintf('The field "%s" value is "%s", but "%s" expected.', $field, htmlspecialchars($actual), htmlspecialchars($value));
$assert_session->assert((bool) preg_match($regex, $actual), $message);
}
/**
* Checks that no real-time validation errors are present.
*
* @throws \Behat\Mink\Exception\ElementNotFoundException
*/
protected function assertNoRealtimeValidationErrors(): void {
$assert_session = $this->assertSession();
$this->assertSame('', $assert_session->elementExists('css', '[data-drupal-selector="ckeditor5-realtime-validation-messages-container"]')->getHtml());
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\user\Entity\User;
use Symfony\Component\Validator\ConstraintViolation;
/**
* Tests for CKEditor 5 editor UI with Toolbar module.
*
* @group ckeditor5
* @internal
*/
class CKEditor5ToolbarTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
'toolbar',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The admin user.
*
* @var \Drupal\user\Entity\User
*/
protected User $user;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->drupalCreateContentType([
'type' => 'article',
'name' => 'Article',
]);
$this->user = $this->drupalCreateUser([
'use text format test_format',
'access toolbar',
'edit any article content',
'administer site configuration',
]);
$this->drupalLogin($this->user);
}
/**
* Ensures that CKEditor 5 toolbar renders below Drupal Toolbar.
*/
public function test(): void {
$assert_session = $this->assertSession();
// Create test content to ensure that CKEditor 5 text editor can be
// scrolled.
$body = '';
for ($i = 0; $i < 10; $i++) {
$body .= '<p>' . $this->randomMachineName(32) . '</p>';
}
$edit_url = $this->drupalCreateNode(['type' => 'article', 'body' => ['value' => $body, 'format' => 'test_format']])->toUrl('edit-form');
$this->drupalGet($edit_url);
$this->assertNotEmpty($assert_session->waitForElement('css', '#toolbar-bar'));
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
// Ensure the body has enough height to enable scrolling. Scroll 110px from
// top of body field to ensure CKEditor 5 toolbar is sticky.
$this->getSession()->evaluateScript('document.body.style.height = "10000px";');
$this->getSession()->evaluateScript('location.hash = "#edit-body-0-value";');
$this->getSession()->evaluateScript('scroll(0, document.documentElement.scrollTop + 110);');
// Focus CKEditor 5 text editor.
$javascript = <<<JS
Drupal.CKEditor5Instances.get(document.getElementById("edit-body-0-value").dataset["ckeditor5Id"]).editing.view.focus();
JS;
$this->getSession()->evaluateScript($javascript);
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-sticky-panel__placeholder'));
$toolbar_height = (int) $this->getSession()->evaluateScript('document.getElementById("toolbar-bar").offsetHeight');
$ckeditor5_toolbar_position = (int) $this->getSession()->evaluateScript("document.querySelector('.ck-toolbar').getBoundingClientRect().top");
$this->assertEqualsWithDelta($toolbar_height, $ckeditor5_toolbar_position, 2);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
// cspell:ignore subtheming
/**
* Tests warnings when ckeditor_stylesheets do not have CKEditor 5 equivalents.
*
* @group ckeditor5
* @internal
*/
class CKEditorStylesheetsWarningTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
}
/**
* Installs and enables themes for testing.
*
* @param string $theme
* The theme to enable.
*/
public function installThemeThatTriggersWarning($theme) {
$theme_installer = \Drupal::service('theme_installer');
$theme_installer->install([$theme]);
$this->config('system.theme')->set('default', $theme)->save();
$theme_installer->install(['stark']);
$this->config('system.theme')->set('admin', 'stark')->save();
\Drupal::service('theme_handler')->refreshInfo();
}
/**
* Test the ckeditor_stylesheets warning in the filter UI.
*
* @dataProvider providerTestWarningFilterUI
*/
public function testWarningFilterUi($theme, $expected_warning): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat($page, $assert_session);
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$assert_session->pageTextNotContains($expected_warning);
$this->installThemeThatTriggersWarning($theme);
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$this->assertTrue($assert_session->waitForText($expected_warning));
}
/**
* Data provider for testWarningFilterUI().
*
* @return string[][]
* An array with the theme to enable and the warning message to check.
*/
public function providerTestWarningFilterUi() {
return [
'single theme' => [
'theme' => 'test_ckeditor_stylesheets_without_5',
'expected_warning' => 'The No setting for CKEditor 5 stylesheets theme has ckeditor_stylesheets configured without a corresponding ckeditor5-stylesheets configuration. See the change record for details.',
],
'with base theme' => [
'theme' => 'test_subtheming_ckeditor_stylesheets_without_5',
'expected_warning' => 'The No setting for CKEditor 5 stylesheets here or subtheme and No setting for CKEditor 5 stylesheets themes have ckeditor_stylesheets configured, but without corresponding ckeditor5-stylesheets configurations. See the change record for details.',
],
];
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Symfony\Component\Validator\ConstraintViolation;
/**
* Tests emphasis in CKEditor 5.
*
* CKEditor's use of <i> is converted to <em> in Drupal, so additional coverage
* is provided here to verify successful conversion.
*
* @group ckeditor5
* @internal
*/
class EmphasisTest extends WebDriverTestBase {
use CKEditor5TestTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* A host entity with a body field to use the <em> tag in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'node',
'text',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <em>',
],
],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'italic',
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]);
$this->drupalCreateContentType(['type' => 'blog']);
$this->host = $this->createNode([
'type' => 'blog',
'title' => 'Animals with strange names',
'body' => [
'value' => '<p>This is a <em>test!</em></p>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
/**
* Ensures that CKEditor italic model is converted to em.
*/
public function testEmphasis(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$emphasis_element = $assert_session->waitForElementVisible('css', '.ck-content p em');
$this->assertEquals('test!', $emphasis_element->getText());
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$emphasis_source = $xpath->query('//p/em');
$this->assertNotEmpty($emphasis_source);
$this->assertEquals('test!', $emphasis_source[0]->textContent);
$page->pressButton('Save');
$assert_session->responseContains('<p>This is a <em>test!</em></p>');
}
/**
* Tests that arbitrary attributes are allowed via GHS.
*/
public function testEmphasisArbitraryHtml(): void {
$assert_session = $this->assertSession();
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
// Allow the data-foo attribute in img via GHS.
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = ['<em data-foo>'];
$editor->setSettings($settings);
$editor->save();
// Add data-foo use to an existing em tag.
$original_value = $this->host->body->value;
$this->host->body->value = str_replace('<em>', '<em data-foo="bar">', $original_value);
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$emphasis_element = $assert_session->waitForElementVisible('css', '.ck-content p em');
$this->assertEquals('bar', $emphasis_element->getAttribute('data-foo'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//em[@data-foo="bar"]'));
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolation;
// cspell:ignore imageresize imageupload
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
* @group ckeditor5
* @group #slow
* @internal
*/
class ImageTest extends ImageTestBase {
/**
* The sample image File entity to embed.
*
* @var \Drupal\file\FileInterface
*/
protected $file;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <em> <a href> <img src alt data-entity-uuid data-entity-type height width data-caption data-align>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'drupalInsertImage',
'sourceEditing',
'link',
'italic',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'ckeditor5_imageResize' => [
'allow_resize' => TRUE,
],
],
],
'image_upload' => [
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '1M',
'max_dimensions' => ['width' => 100, 'height' => 100],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
'administer filters',
]);
// Create a sample host entity to embed images in.
$this->file = File::create([
'uri' => $this->getTestFiles('image')[0]->uri,
]);
$this->file->save();
$this->host = $this->createNode([
'type' => 'page',
'title' => 'Animals with strange names',
'body' => [
'value' => '<p>The pirate is irate.</p>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
/**
* Provides the relevant image attributes.
*
* @return string[]
*/
protected function imageAttributes() {
return [
'data-entity-type' => 'file',
'data-entity-uuid' => $this->file->uuid(),
'src' => $this->file->createFileUrl(),
'width' => '40',
'height' => '20',
];
}
protected function addImage() {
$page = $this->getSession()->getPage();
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image = $this->getTestFiles('image')[0];
$image_upload_field->attachFile($this->container->get('file_system')->realpath($image->uri));
// Wait for the image to be uploaded and rendered by CKEditor 5.
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '.ck-widget.image > img[src*="' . $image->filename . '"]'));
}
/**
* Tests the ckeditor5_imageResize and ckeditor5_imageUpload settings forms.
*/
public function testImageSettingsForm(): void {
$assert_session = $this->assertSession();
$this->drupalGet('admin/config/content/formats/manage/test_format');
// The image resize and upload plugin settings forms should be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-imageresize"]');
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image"]');
// Removing the drupalImageInsert button from the toolbar must remove the
// plugin settings forms too.
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowUp');
$assert_session->assertWaitOnAjaxRequest();
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-imageresize"]');
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image"]');
// Re-adding the drupalImageInsert button to the toolbar must re-add the
// plugin settings forms too.
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-imageresize"]');
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image"]');
}
/**
* Tests that it's possible to upload SVG image, with the test module enabled.
*/
public function testCanUploadSvg(): void {
$this->container->get('module_installer')
->install(['ckeditor5_test_module_allowed_image']);
$page = $this->getSession()->getPage();
$src = 'core/modules/ckeditor5/tests/fixtures/test-svg-upload.svg';
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image_upload_field->attachFile($this->container->get('file_system')->realpath($src));
// Wait for the image to be uploaded and rendered by CKEditor 5.
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '.ck-widget.image-inline > img[src$="test-svg-upload.svg"]'));
}
}

View File

@@ -0,0 +1,688 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Component\Utility\Html;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
// cspell:ignore imageresize
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
* @group ckeditor5
* @internal
*/
abstract class ImageTestBase extends CKEditor5TestBase {
use CKEditor5TestTrait;
use TestFileCreationTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* A host entity with a body field to embed images in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'node',
'text',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Provides the relevant image attributes.
*
* @return string[]
*/
protected function imageAttributes() {
return [
'src' => base_path() . 'core/misc/druplicon.png',
'width' => '88',
'height' => '100',
];
}
/**
* Helper to format attributes.
*
* @param bool $reverse
* Reverse attributes when printing them.
*
* @return string
*/
protected function imageAttributesAsString($reverse = FALSE) {
$string = [];
foreach ($this->imageAttributes() as $key => $value) {
$string[] = $key . '="' . $value . '"';
}
if ($reverse) {
$string = array_reverse($string);
}
return implode(' ', $string);
}
/**
* Add an image to the CKEditor 5 editable zone.
*/
protected function addImage() {
$page = $this->getSession()->getPage();
$src = $this->imageAttributes()['src'];
$this->waitForEditor();
$this->pressEditorButton('Insert image via URL');
$panel = $page->find('css', '.ck-dropdown__panel .ck-image-insert-url');
$src_input = $panel->find('css', 'input[type=text]');
$src_input->setValue($src);
$panel->find('xpath', "//button[span[text()='Insert']]")->click();
// Wait for the image to be uploaded and rendered by CKEditor 5.
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '.ck-widget.image > img[src="' . $src . '"]'));
}
/**
* Ensures that attributes are retained on conversion.
*/
public function testAttributeRetentionDuringUpcasting(): void {
// Run test cases in a single test to make the test run faster.
$attributes_to_retain = [
'-none-' => 'inline',
'data-caption="test caption 🦙"' => 'block',
'data-align="left"' => 'inline',
];
foreach ($attributes_to_retain as $attribute_to_retain => $expected_upcast_behavior_when_wrapped_in_block_element) {
if ($attribute_to_retain === '-none-') {
$attribute_to_retain = '';
}
$img_tag = '<img ' . $attribute_to_retain . ' alt="drupalimage test image" ' . $this->imageAttributesAsString() . ' />';
$test_cases = [
// Plain image tag for a baseline.
[
$img_tag,
$img_tag,
],
// Image tag wrapped with <p>.
[
"<p>$img_tag</p>",
$expected_upcast_behavior_when_wrapped_in_block_element === 'inline' ? "<p>$img_tag</p>" : $img_tag,
],
// Image tag wrapped with a disallowed paragraph-like element (<div).
// When inline is the expected upcast behavior, it will wrap in <p>
// because it still must wrap in a paragraph-like element, and <p> is
// available to be that element.
[
"<div>$img_tag</div>",
$expected_upcast_behavior_when_wrapped_in_block_element === 'inline' ? "<p>$img_tag</p>" : $img_tag,
],
];
foreach ($test_cases as $test_case) {
[$markup, $expected] = $test_case;
$this->host->body->value = $markup;
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Ensure that the image is rendered in preview.
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', ".ck-content .ck-widget img"));
$editor_dom = $this->getEditorDataAsDom();
$expected_dom = Html::load($expected);
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertEquals($expected_dom->getElementsByTagName('body')->item(0)->C14N(), $editor_dom->getElementsByTagName('body')->item(0)->C14N());
// Ensure the test attribute is persisted on downcast.
if ($attribute_to_retain) {
$this->assertNotEmpty($xpath->query("//img[@$attribute_to_retain]"));
}
}
}
}
/**
* Tests that arbitrary attributes are allowed via GHS.
*
* @dataProvider providerLinkability
*/
public function testImageArbitraryHtml(string $image_type, bool $unrestricted): void {
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
// Allow the data-foo attribute in img via GHS.
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = ['<img data-foo>'];
$editor->setSettings($settings);
$editor->save();
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
}
// Make the test content have either a block image or an inline image.
$img_tag = '<img data-foo="bar" alt="drupalimage test image" data-entity-type="file" ' . $this->imageAttributesAsString() . ' />';
$this->host->body->value .= $image_type === 'block'
? $img_tag
: "<p>$img_tag</p>";
$this->host->save();
$expected_widget_selector = $image_type === 'block' ? 'image img' : 'image-inline';
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$drupalimage = $this->assertSession()->waitForElementVisible('css', ".ck-content .ck-widget.$expected_widget_selector");
$this->assertNotEmpty($drupalimage);
$this->assertEquals('bar', $drupalimage->getAttribute('data-foo'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//img[@data-foo="bar"]'));
}
/**
* Tests linkability of the image CKEditor widget.
*
* Due to the complex overrides that `drupalImage.DrupalImage` is making, this
* is explicitly testing the "editingDowncast" and "dataDowncast" results.
* These are CKEditor 5 concepts.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion
*
* @dataProvider providerLinkability
*/
public function testLinkability(string $image_type, bool $unrestricted): void {
assert($image_type === 'inline' || $image_type === 'block');
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
}
// Make the test content have either a block image or an inline image.
$img_tag = '<img alt="drupalimage test image" data-entity-type="file" ' . $this->imageAttributesAsString() . ' />';
$this->host->body->value .= $image_type === 'block'
? $img_tag
: "<p>$img_tag</p>";
$this->host->save();
// Adjust the expectations accordingly.
$expected_widget_class = $image_type === 'block' ? 'image' : 'image-inline';
$page = $this->getSession()->getPage();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
// Initial state: the image CKEditor Widget is not selected.
$drupalimage = $assert_session->waitForElementVisible('css', ".ck-content .ck-widget.$expected_widget_class");
$this->assertNotEmpty($drupalimage);
$this->assertFalse($drupalimage->hasClass('.ck-widget_selected'));
$src = basename($this->imageAttributes()['src']);
// Assert the "editingDowncast" HTML before making changes.
$assert_session->elementExists('css', '.ck-content .ck-widget.' . $expected_widget_class . ' > img[src*="' . $src . '"][alt="drupalimage test image"]');
// Assert the "dataDowncast" HTML before making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//img[@alt="drupalimage test image"]'));
$this->assertEmpty($xpath->query('//a'));
// Assert the link button is present and not pressed.
$link_button = $this->getEditorButton('Link');
$this->assertSame('false', $link_button->getAttribute('aria-pressed'));
// Tests linking images.
$drupalimage->click();
$this->assertTrue($drupalimage->hasClass('ck-widget_selected'));
$this->assertEditorButtonEnabled('Link');
// Assert structure of image toolbar balloon.
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Image toolbar"]');
$link_image_button = $this->getBalloonButton('Link image');
// Click the "Link image" button.
$this->assertSame('false', $link_image_button->getAttribute('aria-pressed'));
$link_image_button->press();
// Assert structure of link form balloon.
$balloon = $this->assertVisibleBalloon('.ck-link-form');
$url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text');
// Fill in link form balloon's <input> and hit "Save".
$url_input->setValue('http://www.drupal.org/association');
$balloon->pressButton('Save');
// Assert the "editingDowncast" HTML after making changes. First assert the
// link exists, then assert the expected DOM structure in detail.
$assert_session->elementExists('css', '.ck-content a[href*="//www.drupal.org/association"]');
// For inline images, the link is wrapping the widget; for block images the
// link lives inside the widget. (This is how it is implemented upstream, it
// could be implemented differently, we just want to ensure we do not break
// it. Drupal only cares about having its own "dataDowncast", the
// "editingDowncast" is considered an implementation detail.)
$assert_session->elementExists('css', $image_type === 'inline'
? '.ck-content a[href*="//www.drupal.org/association"] .ck-widget.' . $expected_widget_class . ' > img[src*="' . $src . '"][alt="drupalimage test image"]'
: '.ck-content .ck-widget.' . $expected_widget_class . ' a[href*="//www.drupal.org/association"] > img[src*="' . $src . '"][alt="drupalimage test image"]'
);
// Assert the "dataDowncast" HTML after making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertCount(1, $xpath->query('//a[@href="http://www.drupal.org/association"]/img[@alt="drupalimage test image"]'));
$this->assertEmpty($xpath->query('//a[@href="http://www.drupal.org/association" and @class="trusted"]'));
// Add `class="trusted"` to the link.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertEmpty($xpath->query('//a[@href="http://www.drupal.org/association" and @class="trusted"]'));
$this->pressEditorButton('Source');
$source_text_area = $assert_session->waitForElement('css', '.ck-source-editing-area textarea');
$this->assertNotEmpty($source_text_area);
$new_value = str_replace('<a ', '<a class="trusted" ', $source_text_area->getValue());
$source_text_area->setValue('<p>temp</p>');
$source_text_area->setValue($new_value);
$this->pressEditorButton('Source');
// When unrestricted, additional attributes on links should be retained.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertCount($unrestricted ? 1 : 0, $xpath->query('//a[@href="http://www.drupal.org/association" and @class="trusted"]'));
// Save the entity whose text field is being edited.
$page->pressButton('Save');
// Assert the HTML the end user sees.
$assert_session->elementExists('css', $unrestricted
? 'a[href="http://www.drupal.org/association"].trusted img[src*="' . $src . '"]'
: 'a[href="http://www.drupal.org/association"] img[src*="' . $src . '"]');
// Go back to edit the now *linked* <drupal-media>. Everything from this
// point onwards is effectively testing "upcasting" and proving there is no
// data loss.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Assert the "dataDowncast" HTML before making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//img[@alt="drupalimage test image"]'));
$this->assertNotEmpty($xpath->query('//a[@href="http://www.drupal.org/association"]'));
$this->assertNotEmpty($xpath->query('//a[@href="http://www.drupal.org/association"]/img[@alt="drupalimage test image"]'));
$this->assertCount($unrestricted ? 1 : 0, $xpath->query('//a[@href="http://www.drupal.org/association" and @class="trusted"]'));
// Tests unlinking images.
$drupalimage->click();
$this->assertEditorButtonEnabled('Link');
$this->assertSame('true', $this->getEditorButton('Link')->getAttribute('aria-pressed'));
// Assert structure of image toolbar balloon.
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Image toolbar"]');
$link_image_button = $this->getBalloonButton('Link image');
$this->assertSame('true', $link_image_button->getAttribute('aria-pressed'));
$link_image_button->click();
// Assert structure of link actions balloon.
$this->getBalloonButton('Edit link');
$unlink_image_button = $this->getBalloonButton('Unlink');
// Click the "Unlink" button.
$unlink_image_button->click();
$this->assertSame('false', $this->getEditorButton('Link')->getAttribute('aria-pressed'));
// Assert the "editingDowncast" HTML after making changes. Assert the
// widget exists but not the link, or *any* link for that matter. Then
// assert the expected DOM structure in detail.
$assert_session->elementExists('css', '.ck-content .ck-widget.' . $expected_widget_class);
$assert_session->elementNotExists('css', '.ck-content a');
$assert_session->elementExists('css', '.ck-content .ck-widget.' . $expected_widget_class . ' > img[src*="' . $src . '"][alt="drupalimage test image"]');
// Assert the "dataDowncast" HTML after making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertCount(0, $xpath->query('//a[@href="http://www.drupal.org/association"]/img[@alt="drupalimage test image"]'));
$this->assertCount(1, $xpath->query('//img[@alt="drupalimage test image"]'));
$this->assertCount(0, $xpath->query('//a'));
}
/**
* Tests that alt text is required for images.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion
*
* @dataProvider providerAltTextRequired
*/
public function testAltTextRequired(bool $unrestricted): void {
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
}
// Make the test content has a block image and an inline image.
$img_tag = preg_replace(
'/width="\d+" height="\d+"/',
'width="500"',
'<img ' . $this->imageAttributesAsString() . ' />'
);
$this->host->body->value .= $img_tag . "<p>$img_tag</p>";
$this->host->save();
$page = $this->getSession()->getPage();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
// Confirm both of the images exist.
$this->assertNotEmpty($image_block = $assert_session->waitForElementVisible('css', ".ck-content .ck-widget.image"));
$this->assertNotEmpty($image_inline = $assert_session->waitForElementVisible('css', ".ck-content .ck-widget.image-inline"));
// Confirm both of the images have an alt text required warning.
$this->assertNotEmpty($image_block->find('css', '.image-alternative-text-missing-wrapper'));
$this->assertNotEmpty($image_inline->find('css', '.image-alternative-text-missing-wrapper'));
// Add alt text to the block image.
$image_block->find('css', '.image-alternative-text-missing button')->click();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-balloon-panel'));
$this->assertVisibleBalloon('.ck-text-alternative-form');
// Ensure that the missing alt text warning is hidden when the alternative
// text form is open.
$assert_session->waitForElement('css', '.ck-content .ck-widget.image .image-alternative-text-missing.ck-hidden');
$assert_session->elementExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing');
$assert_session->elementNotExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing.ck-hidden');
// Ensure that the missing alt text error is not added to decorative images.
$this->assertNotEmpty($decorative_button = $this->getBalloonButton('Decorative image'));
$assert_session->elementExists('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]');
$decorative_button->click();
$assert_session->elementExists('css', '.ck-content .ck-widget.image .image-alternative-text-missing.ck-hidden');
$assert_session->elementExists('css', ".ck-content .ck-widget.image-inline .image-alternative-text-missing-wrapper");
$assert_session->elementNotExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing.ck-hidden');
// Ensure that the missing alt text error is removed after saving the
// changes.
$this->assertNotEmpty($save_button = $this->getBalloonButton('Save'));
$save_button->click();
$this->assertTrue($assert_session->waitForElementRemoved('css', ".ck-content .ck-widget.image .image-alternative-text-missing-wrapper"));
$assert_session->elementExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing-wrapper');
// Ensure that the decorative image downcasts into empty alt attribute.
$editor_dom = $this->getEditorDataAsDom();
$decorative_img = $editor_dom->getElementsByTagName('img')->item(0);
$this->assertTrue($decorative_img->hasAttribute('alt'));
$this->assertEmpty($decorative_img->getAttribute('alt'));
// Ensure that missing alt text error is not added to images with alt text.
$this->assertNotEmpty($alt_text_button = $this->getBalloonButton('Change image alternative text'));
$alt_text_button->click();
$decorative_button->click();
$this->assertNotEmpty($save_button = $this->getBalloonButton('Save'));
$this->assertTrue($save_button->hasClass('ck-disabled'));
$this->assertNotEmpty($alt_override_input = $page->find('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]'));
$alt_override_input->setValue('There is now alt text');
$this->assertTrue($assert_session->waitForElementRemoved('css', '.ck-balloon-panel .ck-text-alternative-form .ck-disabled'));
$this->assertFalse($save_button->hasClass('ck-disabled'));
$save_button->click();
// Save the node and confirm that the alt text is retained.
$page->pressButton('Save');
$this->assertNotEmpty($assert_session->waitForElement('css', 'img[alt="There is now alt text"]'));
// Ensure that alt form is opened after image upload.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->addImage();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-text-alternative-form'));
$this->assertVisibleBalloon('.ck-text-alternative-form');
}
public static function providerAltTextRequired(): array {
return [
'Restricted' => [FALSE],
'Unrestricted' => [TRUE],
];
}
public static function providerLinkability(): array {
return [
'BLOCK image, restricted' => ['block', FALSE],
'BLOCK image, unrestricted' => ['block', TRUE],
'INLINE image, restricted' => ['inline', FALSE],
'INLINE image, unrestricted' => ['inline', TRUE],
];
}
/**
* Tests alignment integration.
*
* @dataProvider providerAlignment
*/
public function testAlignment(string $image_type): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
// Make the test content have either a block image or an inline image.
$img_tag = '<img alt="drupalimage test image" ' . $this->imageAttributesAsString() . ' />';
$this->host->body->value .= $image_type === 'block'
? $img_tag
: "<p>$img_tag</p>";
$this->host->save();
$image_selector = $image_type === 'block' ? '.ck-widget.image' : '.ck-widget.image-inline';
$default_alignment = $image_type === 'block' ? 'Break text' : 'In line';
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $image_selector));
// Ensure that the default alignment option matches expectation.
$this->click($image_selector);
$this->assertVisibleBalloon('[aria-label="Image toolbar"]');
$this->assertTrue($this->getBalloonButton($default_alignment)->hasClass('ck-on'));
$editor_dom = $this->getEditorDataAsDom();
$drupal_media_element = $editor_dom->getElementsByTagName('img')
->item(0);
$this->assertFalse($drupal_media_element->hasAttribute('data-align'));
$this->getBalloonButton('Align center and break text')->click();
// Assert the alignment class exists after editing downcast.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-widget.image.image-style-align-center'));
$editor_dom = $this->getEditorDataAsDom();
$drupal_media_element = $editor_dom->getElementsByTagName('img')
->item(0);
$this->assertEquals('center', $drupal_media_element->getAttribute('data-align'));
$page->pressButton('Save');
// Check that the 'content has been updated' message status appears to confirm we left the editor.
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '[data-drupal-messages]'));
// Check that the class is correct in the front end.
$assert_session->elementExists('css', 'img.align-center');
// Go back to the editor to check that the alignment class still exists.
$edit_url = $this->getSession()->getCurrentURL() . '/edit';
$this->drupalGet($edit_url);
$this->waitForEditor();
$assert_session->elementExists('css', '.ck-widget.image.image-style-align-center');
// Ensure that "Centered image" alignment option is selected.
$this->click('.ck-widget.image');
$this->assertVisibleBalloon('[aria-label="Image toolbar"]');
$this->assertTrue($this->getBalloonButton('Align center and break text')->hasClass('ck-on'));
$this->getBalloonButton('Break text')->click();
$this->assertTrue($assert_session->waitForElementRemoved('css', '.ck-widget.image.image-style-align-center'));
$editor_dom = $this->getEditorDataAsDom();
$drupal_media_element = $editor_dom->getElementsByTagName('img')
->item(0);
$this->assertFalse($drupal_media_element->hasAttribute('data-align'));
}
public static function providerAlignment() {
return [
'Block image' => ['block'],
'Inline image' => ['inline'],
];
}
/**
* Ensures that width attribute upcasts and downcasts correctly.
*
* @param string $width
* The width input for the image.
*
* @dataProvider providerWidth
*/
public function testWidth(string $width): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Despite the absence of a `height` attribute on the `<img>`, CKEditor 5
// should generate an appropriate `height`, matching with the aspect ratio
// of the image.
$expected_computed_height = $width;
if (!str_ends_with($width, '%')) {
$ratio = $width / (int) $this->imageAttributes()['width'];
$expected_computed_height = (string) (int) round($ratio * (int) $this->imageAttributes()['height']);
}
// Add image to the host body.
$this->host->body->value = sprintf('<img data-foo="bar" alt="drupalimage test image" ' . $this->imageAttributesAsString() . ' width="%s" />', $width);
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Ensure that the image is upcast as expected. In the editing view, the
// width attribute should downcast to an inline style on the container
// element.
$assert_session->waitForElementVisible('css', ".ck-widget.image");
$this->assertNotEmpty($assert_session->waitForElementVisible('css', ".ck-widget.image[style] img"));
// Ensure that the width attribute is retained on downcast.
$editor_data = $this->getEditorDataAsDom();
$img_in_editor = $editor_data->getElementsByTagName('img')->item(0);
$this->assertSame($width, $img_in_editor->getAttribute('width'));
$this->assertSame($expected_computed_height, $img_in_editor->getAttribute('height'));
// Save the node and ensure that the width attribute is retained, and ensure
// that a natural image ratio-respecting height attribute has been added.
$page->pressButton('Save');
$this->assertNotEmpty($assert_session->waitForElement('css', "img[width='$width'][height='$expected_computed_height']"));
}
/**
* Ensures that images can have caption set.
*/
public function testImageCaption(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// The foo attribute is added to be removed later by CKEditor 5 to make sure
// CKEditor 5 was able to downcast data.
$img_tag = '<img ' . $this->imageAttributesAsString() . ' alt="drupalimage test image" data-caption="Alpacas &lt;em&gt;are&lt;/em&gt; cute&lt;br&gt;really!" foo="bar">';
$this->host->body->value = $img_tag;
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$this->assertNotEmpty($figcaption = $assert_session->waitForElement('css', '.image figcaption'));
$this->assertSame('Alpacas <em>are</em> cute<br>really!', $figcaption->getHtml());
$page->pressButton('Source');
$editor_dom = $this->getEditorDataAsDom();
$data_caption = $editor_dom->getElementsByTagName('img')->item(0)->getAttribute('data-caption');
$this->assertSame('Alpacas <em>are</em> cute<br>really!', $data_caption);
$page->pressButton('Save');
$src = $this->imageAttributes()['src'];
$expected = '<img ' . $this->imageAttributesAsString(TRUE) . ' alt="drupalimage test image" data-caption="Alpacas &lt;em&gt;are&lt;/em&gt; cute&lt;br&gt;really!">';
$expected_dom = Html::load($expected);
$this->assertEquals($expected_dom->getElementsByTagName('body')->item(0)->C14N(), $editor_dom->getElementsByTagName('body')->item(0)->C14N());
$assert_session->elementExists('xpath', '//figure/img[@src="' . $src . '" and not(@data-caption)]');
$assert_session->responseContains('<figcaption>Alpacas <em>are</em> cute<br>really!</figcaption>');
}
/**
* Data provider for ::testWidth().
*
* @return string[][]
*/
public static function providerWidth(): array {
return [
'Image resize with percent unit (only allowed in HTML 4)' => [
'width' => '33%',
],
'Image resize with (implied) px unit' => [
'width' => '100',
],
];
}
/**
* Tests the image resize plugin.
*
* Confirms that enabling the resize plugin introduces the resize class to
* images within CKEditor 5.
*
* @param bool $is_resize_enabled
* Boolean flag to test enabled or disabled.
*
* @dataProvider providerResize
*/
public function testResize(bool $is_resize_enabled): void {
// Disable resize plugin because it is enabled by default.
if (!$is_resize_enabled) {
Editor::load('test_format')->setSettings([
'toolbar' => [
'items' => [
'drupalInsertImage',
],
],
'plugins' => [
'ckeditor5_imageResize' => [
'allow_resize' => FALSE,
],
],
])->save();
}
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('node/add');
$page->fillField('title[0][value]', 'My test content');
$this->addImage();
$image_figure = $assert_session->waitForElementVisible('css', 'figure');
$this->assertSame($is_resize_enabled, $image_figure->hasClass('ck-widget_with-resizer'));
}
/**
* Data provider for ::testResize().
*
* @return array
* The test cases.
*/
public static function providerResize(): array {
return [
'Image resize is enabled' => [
'is_resize_enabled' => TRUE,
],
'Image resize is disabled' => [
'is_resize_enabled' => FALSE,
],
];
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Symfony\Component\Validator\ConstraintViolation;
// cspell:ignore imageresize
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
* @group ckeditor5
* @group #slow
* @internal
*/
class ImageUrlTest extends ImageTestBase {
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <em> <a href> <img alt height width src data-caption data-align>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'drupalInsertImage',
'sourceEditing',
'link',
'italic',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'ckeditor5_imageResize' => [
'allow_resize' => TRUE,
],
],
],
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
'administer filters',
]);
$this->host = $this->createNode([
'type' => 'page',
'title' => 'Animals with strange names',
'body' => [
'value' => '<p>The pirate is irate.</p>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
/**
* Tests the Drupal image URL widget.
*/
public function testImageUrlWidget(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$image_selector = '.ck-widget.image-inline';
$src = $this->imageAttributes()['src'];
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->pressEditorButton('Insert image via URL');
$panel = $page->find('css', '.ck-dropdown__panel .ck-image-insert-url');
$src_input = $panel->find('css', 'input[type=text]');
$src_input->setValue($src);
$panel->find('xpath', "//button[span[text()='Insert']]")->click();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $image_selector));
$this->click($image_selector);
$this->assertVisibleBalloon('[aria-label="Image toolbar"]');
$this->pressEditorButton('Update image URL');
$panel = $page->find('css', '.ck-dropdown__panel .ck-image-insert-url');
$src_input = $panel->find('css', 'input[type=text]');
$this->assertEquals($src, $src_input->getValue());
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
/**
* Tests for CKEditor 5 plugins using Drupal's translation system.
*
* @group ckeditor5
* @internal
*/
class JSTranslationTest extends CKEditor5TestBase {
use MediaTypeCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'language',
'locale',
'media_library',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create a sample media entity to be embedded.
$this->createMediaType('image', ['id' => 'image', 'label' => 'Image']);
}
/**
* Integration test to ensure that CKEditor 5 Plugins translations are loaded.
*/
public function test(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalMedia'));
$this->click('#edit-filters-media-embed-status');
$assert_session->assertExpectedAjaxRequest(2);
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalMedia', 'ArrowDown');
$assert_session->assertExpectedAjaxRequest(3);
$this->saveNewTextFormat($page, $assert_session);
$langcode = 'fr';
ConfigurableLanguage::createFromLangcode($langcode)->save();
$this->config('system.site')->set('default_langcode', $langcode)->save();
// Visit a page that will trigger a JavaScript file parsing for
// translatable strings.
$this->drupalGet('node/add');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
// Ensure a string from the CKEditor 5 plugin is picked up by translation.
// @see core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalmediatoolbar.js
$locale_storage = $this->container->get('locale.storage');
$string = $locale_storage->findString(['source' => 'Drupal Media toolbar', 'context' => '']);
$this->assertNotEmpty($string, 'String from JavaScript file saved.');
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\language\Entity\ConfigurableLanguage;
// cspell:ignore คำพูดบล็อก sourceediting
/**
* Tests for CKEditor 5 UI translations.
*
* @group ckeditor5
* @internal
*/
class LanguageTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'language',
'locale',
];
/**
* Integration test to ensure that CKEditor 5 UI translations are loaded.
*
* @param string $langcode
* The language code.
* @param string $toolbar_item_name
* The CKEditor 5 plugin to enable.
* @param string $toolbar_item_translation
* The expected translation for CKEditor 5 plugin toolbar button.
*
* @dataProvider provider
*/
public function test(string $langcode, string $toolbar_item_name, string $toolbar_item_translation): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// Special case: textPartLanguage toolbar item can only create `<span lang>`
// but not `<span>`. The purpose of this test is to test translations, not
// the configuration of the textPartLanguage functionality. So, make sure
// that `<span>` can be created so we can test how UI translations work when
// using `textPartLanguage`.
if ($toolbar_item_name === 'textPartLanguage') {
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and should
// have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<span>';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
}
$this->assertNotEmpty($assert_session->waitForElement('css', ".ckeditor5-toolbar-item-$toolbar_item_name"));
$this->triggerKeyUp(".ckeditor5-toolbar-item-$toolbar_item_name", 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$this->saveNewTextFormat($page, $assert_session);
ConfigurableLanguage::createFromLangcode($langcode)->save();
$this->config('system.site')->set('default_langcode', $langcode)->save();
$this->drupalGet('node/add');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
// Ensure that blockquote button is translated.
$assert_session->elementExists('xpath', "//span[text()='$toolbar_item_translation']");
}
/**
* Data provider for ensuring CKEditor 5 UI translations are loaded.
*
* @return string[][]
*/
public static function provider(): array {
return [
'Language code both in Drupal and CKEditor' => [
'langcode' => 'th',
'toolbar_item_name' => 'blockQuote',
'toolbar_item_translation' => 'คำพูดบล็อก',
],
'Language code transformed from browser mappings' => [
'langcode' => 'zh-hans',
'toolbar_item_name' => 'blockQuote',
'toolbar_item_translation' => '块引用',
],
'Language configuration conflict' => [
'langcode' => 'fr',
'toolbar_item_name' => 'textPartLanguage',
// cSpell:disable-next-line
'toolbar_item_translation' => 'Choisir la langue',
],
];
}
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\media\Entity\Media;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Symfony\Component\Validator\ConstraintViolation;
// cspell:ignore arrakis complote détruire harkonnen
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\MediaLibrary
* @group ckeditor5
* @internal
*/
class MediaLibraryTest extends WebDriverTestBase {
use MediaTypeCreationTrait;
use TestFileCreationTrait;
use CKEditor5TestTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'starterkit_theme';
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $user;
/**
* The media item to embed.
*
* @var \Drupal\media\MediaInterface
*/
protected $media;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'media_library',
'node',
'media',
'text',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'media_embed' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'drupalMedia',
'sourceEditing',
'undo',
'redo',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'media_media' => [
'allow_view_mode_override' => FALSE,
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->drupalCreateContentType(['type' => 'blog']);
// Note that media_install() grants 'view media' to all users by default.
$this->user = $this->drupalCreateUser([
'use text format test_format',
'access media overview',
'create blog content',
]);
// Create a media type that starts with the letter a, to test tab order.
$this->createMediaType('image', ['id' => 'arrakis', 'label' => 'Arrakis']);
// Create a sample media entity to be embedded.
$this->createMediaType('image', ['id' => 'image', 'label' => 'Image']);
File::create([
'uri' => $this->getTestFiles('image')[0]->uri,
])->save();
$this->media = Media::create([
'bundle' => 'image',
'name' => 'Fear is the mind-killer',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'default alt',
'title' => 'default title',
],
],
]);
$this->media->save();
$arrakis_media = Media::create([
'bundle' => 'arrakis',
'name' => 'Le baron Vladimir Harkonnen',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'Il complote pour détruire le duc Leto',
'title' => 'Il complote pour détruire le duc Leto',
],
],
]);
$arrakis_media->save();
$this->drupalLogin($this->user);
}
/**
* Tests using drupalMedia button to embed media into CKEditor 5.
*/
public function testButton(): void {
// Skipped due to frequent random test failures.
// @todo Fix this and stop skipping it at https://www.drupal.org/i/3351597.
$this->markTestSkipped();
$media_preview_selector = '.ck-content .ck-widget.drupal-media .media';
$this->drupalGet('/node/add/blog');
$this->waitForEditor();
$this->pressEditorButton('Insert Media');
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-modal #media-library-content'));
// Ensure that the tab order is correct.
$tabs = $page->findAll('css', '.media-library-menu__link');
$expected_tab_order = [
'Show Image media (selected)',
'Show Arrakis media',
];
foreach ($tabs as $key => $tab) {
$this->assertSame($expected_tab_order[$key], $tab->getText());
}
$assert_session->pageTextContains('0 of 1 item selected');
$assert_session->elementExists('css', '.js-media-library-item')->click();
$assert_session->pageTextContains('1 of 1 item selected');
$assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Insert selected');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $media_preview_selector, 1000));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$drupal_media = $xpath->query('//drupal-media')[0];
$expected_attributes = [
'data-entity-type' => 'media',
'data-entity-uuid' => $this->media->uuid(),
];
foreach ($expected_attributes as $name => $expected) {
$this->assertSame($expected, $drupal_media->getAttribute($name));
}
$this->assertEditorButtonEnabled('Undo');
$this->pressEditorButton('Undo');
$this->assertEmpty($assert_session->waitForElementVisible('css', $media_preview_selector, 1000));
$this->assertEditorButtonDisabled('Undo');
$this->pressEditorButton('Redo');
$this->assertEditorButtonEnabled('Undo');
// Ensure that data-align attribute is set by default when media is inserted
// while filter_align is enabled.
FilterFormat::load('test_format')
->setFilterConfig('filter_align', ['status' => TRUE])
->save();
$this->drupalGet('/node/add/blog');
$this->waitForEditor();
$this->pressEditorButton('Insert Media');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-modal #media-library-content'));
$assert_session->elementExists('css', '.js-media-library-item')->click();
$assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Insert selected');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $media_preview_selector, 1000));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$drupal_media = $xpath->query('//drupal-media')[0];
$expected_attributes = [
'data-entity-type' => 'media',
'data-entity-uuid' => $this->media->uuid(),
];
foreach ($expected_attributes as $name => $expected) {
$this->assertSame($expected, $drupal_media->getAttribute($name));
}
// Ensure that by default, data-align attribute is not set.
$this->assertFalse($drupal_media->hasAttribute('data-align'));
}
/**
* Tests the allowed media types setting on the MediaEmbed filter.
*/
public function testAllowedMediaTypes(): void {
$test_cases = [
'all_media_types' => [],
'only_image' => ['image' => 'image'],
'only_arrakis' => ['arrakis' => 'arrakis'],
'both_items_checked' => [
'image' => 'image',
'arrakis' => 'arrakis',
],
];
foreach ($test_cases as $allowed_media_types) {
// Update the filter format to set the allowed media types.
FilterFormat::load('test_format')
->setFilterConfig('media_embed', [
'status' => TRUE,
'settings' => [
'allowed_media_types' => $allowed_media_types,
],
])->save();
// Now test opening the media library from the CKEditor plugin, and
// verify the expected behavior.
$this->drupalGet('/node/add/blog');
$this->waitForEditor();
$this->pressEditorButton('Insert Media');
$assert_session = $this->assertSession();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-modal #media-library-wrapper'));
if (empty($allowed_media_types) || count($allowed_media_types) === 2) {
$menu = $assert_session->elementExists('css', '.js-media-library-menu');
$assert_session->elementExists('named', ['link', 'Image'], $menu);
$assert_session->elementExists('named', ['link', 'Arrakis'], $menu);
$assert_session->elementTextContains('css', '.js-media-library-item', 'Fear is the mind-killer');
}
elseif (count($allowed_media_types) === 1 && !empty($allowed_media_types['image'])) {
// No tabs should appear if there's only one media type available.
$assert_session->elementNotExists('css', '.js-media-library-menu');
$assert_session->elementTextContains('css', '.js-media-library-item', 'Fear is the mind-killer');
}
elseif (count($allowed_media_types) === 1 && !empty($allowed_media_types['arrakis'])) {
// No tabs should appear if there's only one media type available.
$assert_session->elementNotExists('css', '.js-media-library-menu');
$assert_session->elementTextContains('css', '.js-media-library-item', 'Le baron Vladimir Harkonnen');
}
}
}
/**
* Ensures that alt text can be changed on Media Library inserted Media.
*/
public function testAlt(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('/node/add/blog');
$this->waitForEditor();
$this->pressEditorButton('Insert Media');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-modal #media-library-content'));
$assert_session->elementExists('css', '.js-media-library-item')->click();
$assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Insert selected');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-widget.drupal-media img'));
// Test that clicking the media widget triggers a CKEditor balloon panel
// with a single button to override the alt text.
$this->click('.ck-widget.drupal-media');
$this->assertVisibleBalloon('[aria-label="Drupal Media toolbar"]');
// Click the "Override media image text alternative" button.
$this->getBalloonButton('Override media image alternative text')->click();
$this->assertVisibleBalloon('.ck-media-alternative-text-form');
// Assert that the value is currently empty.
$alt_override_input = $page->find('css', '.ck-balloon-panel .ck-media-alternative-text-form input[type=text]');
$this->assertSame('', $alt_override_input->getValue());
$test_alt = 'Alt text override';
$alt_override_input->setValue($test_alt);
$this->getBalloonButton('Save')->click();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-widget.drupal-media img[alt*="' . $test_alt . '"]'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$drupal_media = $xpath->query('//drupal-media')[0];
$this->assertEquals($test_alt, $drupal_media->getAttribute('alt'));
}
}

View File

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolation;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media
* @group ckeditor5
* @group #slow
* @internal
*/
class MediaLinkabilityTest extends MediaTestBase {
/**
* Ensures arbitrary attributes can be added on links wrapping media via GHS.
*
* @dataProvider providerLinkability
*/
public function testLinkedMediaArbitraryHtml(bool $unrestricted): void {
$assert_session = $this->assertSession();
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
$filter_format = $editor->getFilterFormat();
if ($unrestricted) {
$filter_format
->setFilterConfig('filter_html', ['status' => FALSE]);
}
else {
// Allow the data-foo attribute in <a> via GHS. Also, add support for div's
// with data-foo attribute to ensure that linked drupal-media elements can
// be wrapped with <div>.
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = ['<a data-foo>', '<div data-bar>'];
$editor->setSettings($settings);
$filter_format->setFilterConfig('filter_html', [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <strong> <em> <a href data-foo> <drupal-media data-entity-type data-entity-uuid data-align data-caption alt data-view-mode> <div data-bar>',
],
]);
}
$editor->save();
$filter_format->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
// Wrap the existing drupal-media tag with a div and an a that include
// attributes allowed via GHS.
$original_value = $this->host->body->value;
$this->host->body->value = '<div data-bar="baz"><a href="https://example.com" data-foo="bar">' . $original_value . '</a></div>';
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
// Confirm data-foo is present in the editing view.
$this->assertNotEmpty($link = $assert_session->waitForElementVisible('css', 'a[href="https://example.com"]'));
$this->assertEquals('bar', $link->getAttribute('data-foo'));
// Confirm that the media is wrapped by the div on the editing view.
$assert_session->elementExists('css', 'div[data-bar="baz"] > .drupal-media > a[href="https://example.com"] > div[data-drupal-media-preview]');
// Confirm that drupal-media is wrapped by the div and a, and that GHS has
// retained arbitrary HTML allowed by source editing.
$editor_dom = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($editor_dom->query('//div[@data-bar="baz"]/a[@data-foo="bar"]/drupal-media'));
}
/**
* Tests linkability of the media CKEditor widget.
*
* Due to the very different HTML markup generated for the editing view and
* the data view, this is explicitly testing the "editingDowncast" and
* "dataDowncast" results. These are CKEditor 5 concepts.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion
*
* @dataProvider providerLinkability
*/
public function testLinkability(bool $unrestricted): void {
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
}
$page = $this->getSession()->getPage();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
// Initial state: the Drupal Media CKEditor Widget is not selected.
$drupalmedia = $assert_session->waitForElementVisible('css', '.ck-content .ck-widget.drupal-media');
$this->assertNotEmpty($drupalmedia);
$this->assertFalse($drupalmedia->hasClass('.ck-widget_selected'));
// Assert the "editingDowncast" HTML before making changes.
$assert_session->elementExists('css', '.ck-content .ck-widget.drupal-media > [data-drupal-media-preview]');
// Assert the "dataDowncast" HTML before making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//drupal-media'));
$this->assertEmpty($xpath->query('//a'));
// Assert the link button is present and not pressed.
$link_button = $this->getEditorButton('Link');
$this->assertSame('false', $link_button->getAttribute('aria-pressed'));
// Wait for the preview to load.
$preview = $assert_session->waitForElement('css', '.ck-content .ck-widget.drupal-media [data-drupal-media-preview="ready"]');
$this->assertNotEmpty($preview);
// Tests linking Drupal media.
$drupalmedia->click();
$this->assertTrue($drupalmedia->hasClass('ck-widget_selected'));
$this->assertEditorButtonEnabled('Link');
// Assert structure of image toolbar balloon.
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
$link_media_button = $this->getBalloonButton('Link media');
// Click the "Link media" button.
$this->assertSame('false', $link_media_button->getAttribute('aria-pressed'));
$link_media_button->press();
// Assert structure of link form balloon.
$balloon = $this->assertVisibleBalloon('.ck-link-form');
$url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text');
// Fill in link form balloon's <input> and hit "Save".
$url_input->setValue('http://linking-embedded-media.com');
$balloon->pressButton('Save');
// Assert the "editingDowncast" HTML after making changes. Assert the link
// exists, then assert the link exists. Then assert the expected DOM
// structure in detail.
$assert_session->elementExists('css', '.ck-content a[href="http://linking-embedded-media.com"]');
$assert_session->elementExists('css', '.ck-content .drupal-media.ck-widget > a[href="http://linking-embedded-media.com"] > div[aria-label] > article > div > img[src*="image-test.png"]');
// Assert the "dataDowncast" HTML after making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//drupal-media'));
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]'));
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]/drupal-media'));
// Ensure that the media caption is retained and not linked as a result of
// linking media.
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]/drupal-media[@data-caption="baz"]'));
// Add `class="trusted"` to the link.
$this->assertEmpty($xpath->query('//a[@href="http://linking-embedded-media.com" and @class="trusted"]'));
$this->pressEditorButton('Source');
$source_text_area = $assert_session->waitForElement('css', '.ck-source-editing-area textarea');
$this->assertNotEmpty($source_text_area);
$new_value = str_replace('<a ', '<a class="trusted" ', $source_text_area->getValue());
$source_text_area->setValue('<p>temp</p>');
$source_text_area->setValue($new_value);
$this->pressEditorButton('Source');
// When unrestricted, additional attributes on links should be retained.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertCount($unrestricted ? 1 : 0, $xpath->query('//a[@href="http://linking-embedded-media.com" and @class="trusted"]'));
// Save the entity whose text field is being edited.
$page->pressButton('Save');
// Assert the HTML the end user sees.
$assert_session->elementExists('css', $unrestricted
? 'a[href="http://linking-embedded-media.com"].trusted img[src*="image-test.png"]'
: 'a[href="http://linking-embedded-media.com"] img[src*="image-test.png"]');
// Go back to edit the now *linked* <drupal-media>. Everything from this
// point onwards is effectively testing "upcasting" and proving there is no
// data loss.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Assert the "dataDowncast" HTML before making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//drupal-media'));
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]'));
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]/drupal-media'));
// Tests unlinking media.
$drupalmedia->click();
$this->assertEditorButtonEnabled('Link');
$this->assertSame('true', $this->getEditorButton('Link')->getAttribute('aria-pressed'));
// Assert structure of Drupal media toolbar balloon.
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
$link_media_button = $this->getBalloonButton('Link media');
$this->assertSame('true', $link_media_button->getAttribute('aria-pressed'));
$link_media_button->click();
// Assert structure of link actions balloon.
$this->getBalloonButton('Edit link');
$unlink_image_button = $this->getBalloonButton('Unlink');
// Click the "Unlink" button.
$unlink_image_button->click();
$this->assertSame('false', $this->getEditorButton('Link')->getAttribute('aria-pressed'));
// Assert the "editingDowncast" HTML after making changes. Assert the link
// exists, then assert no link exists. Then assert the expected DOM
// structure in detail.
$assert_session->elementNotExists('css', '.ck-content a');
$assert_session->elementExists('css', '.ck-content .drupal-media.ck-widget > div[aria-label] > article > div > img[src*="image-test.png"]');
// Ensure that figcaption exists.
// @see https://www.drupal.org/project/drupal/issues/3268318
$assert_session->elementExists('css', '.ck-content .drupal-media.ck-widget > figcaption');
// Assert the "dataDowncast" HTML after making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//drupal-media'));
$this->assertEmpty($xpath->query('//a'));
}
public static function providerLinkability(): array {
return [
'restricted' => [FALSE],
'unrestricted' => [TRUE],
];
}
/**
* Ensure that manual link decorators work with linkable media.
*
* @dataProvider providerLinkability
*/
public function testLinkManualDecorator(bool $unrestricted): void {
\Drupal::service('module_installer')->install(['ckeditor5_manual_decorator_test']);
$this->resetAll();
$decorator = 'Open in a new tab';
$decorator_attributes = '[@target="_blank"][@rel="noopener noreferrer"][@class="link-new-tab"]';
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
$decorator = 'Pink color';
$decorator_attributes = '[@style="color:pink;"]';
}
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$this->assertNotEmpty($drupalmedia = $assert_session->waitForElementVisible('css', '.ck-content .ck-widget.drupal-media'));
$drupalmedia->click();
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
// Turn off caption, so we don't accidentally put our link in that text
// field instead of on the actual media.
$this->getBalloonButton('Toggle caption off')->click();
$assert_session->assertNoElementAfterWait('css', 'figure.drupal-media > figcaption');
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
$this->getBalloonButton('Link media')->click();
$balloon = $this->assertVisibleBalloon('.ck-link-form');
$url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text');
$url_input->setValue('http://linking-embedded-media.com');
$this->getBalloonButton($decorator)->click();
$balloon->pressButton('Save');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.drupal-media a'));
$this->assertVisibleBalloon('.ck-link-actions');
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes"));
$this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes/drupal-media"));
// Ensure that manual decorators upcast correctly.
$page->pressButton('Save');
$this->drupalGet($this->host->toUrl('edit-form'));
$this->assertNotEmpty($drupalmedia = $assert_session->waitForElementVisible('css', '.ck-content .ck-widget.drupal-media'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes"));
$this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes/drupal-media"));
// Finally, ensure that media can be unlinked.
$drupalmedia->click();
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
$this->getBalloonButton('Link media')->click();
$this->assertVisibleBalloon('.ck-link-actions');
$this->getBalloonButton('Unlink')->click();
$this->assertTrue($assert_session->waitForElementRemoved('css', '.drupal-media a'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertEmpty($xpath->query('//a'));
$this->assertNotEmpty($xpath->query('//drupal-media'));
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Core\Database\Database;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\filter\Entity\FilterFormat;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media
* @group ckeditor5
* @group #slow
* @internal
*/
class MediaPreviewTest extends MediaTestBase {
/**
* Tests that failed media embed preview requests inform the end user.
*/
public function testErrorMessages(): void {
// This test currently frequently causes the SQLite database to lock, so
// skip the test on SQLite until the issue can be resolved.
// @todo https://www.drupal.org/project/drupal/issues/3273626
if (Database::getConnection()->driver() === 'sqlite') {
$this->markTestSkipped('Test frequently causes a locked database on SQLite');
}
// Assert that a request to the `media.filter.preview` route that does not
// result in a 200 response (due to server error or network error) is
// handled in the JavaScript by displaying the expected error message.
// @see core/modules/media/js/media_embed_ckeditor.theme.js
// @see js/ckeditor5_plugins/drupalMedia/src/drupalmediaediting.js
$this->container->get('state')->set('test_media_filter_controller_throw_error', TRUE);
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
$assert_session->waitForElementVisible('css', '.ck-widget.drupal-media');
$this->assertEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]', 1000));
$assert_session->elementNotExists('css', '.ck-widget.drupal-media .media');
$this->assertNotEmpty($assert_session->waitForText('An error occurred while trying to preview the media. Save your work and reload this page.'));
// Now assert that the error doesn't appear when the override to force an
// error is removed.
$this->container->get('state')->set('test_media_filter_controller_throw_error', FALSE);
$this->getSession()->reload();
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
// There's a second kind of error message that comes from the back end
// that happens when the media uuid can't be converted to a media preview.
// In this case, the error will appear in a the themeable
// media-embed-error.html template. We have a hook altering the css
// classes to test the twig template is working properly and picking up our
// extra class.
// @see \Drupal\media\Plugin\Filter\MediaEmbed::renderMissingMediaIndicator()
// @see core/modules/media/templates/media-embed-error.html.twig
// @see media_test_embed_preprocess_media_embed_error()
$original_value = $this->host->body->value;
$this->host->body->value = str_replace($this->media->uuid(), 'invalid_uuid', $original_value);
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-widget.drupal-media .this-error-message-is-themeable'));
// Test when using the starterkit_theme theme, an additional class is added
// to the error, which is supported by
// stable9/templates/content/media-embed-error.html.twig.
$this->assertTrue($this->container->get('theme_installer')->install(['starterkit_theme']));
$this->config('system.theme')
->set('default', 'starterkit_theme')
->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-widget.drupal-media .this-error-message-is-themeable'));
// Test that restoring a valid UUID results in the media embed preview
// displaying.
$this->host->body->value = $original_value;
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$assert_session->elementNotExists('css', '.ck-widget.drupal-media .this-error-message-is-themeable');
}
/**
* The CKEditor Widget must load a preview generated using the default theme.
*/
public function testPreviewUsesDefaultThemeAndIsClientCacheable(): void {
// Make the node edit form use the admin theme, like on most Drupal sites.
$this->config('node.settings')
->set('use_admin_theme', TRUE)
->save();
// Allow the test user to view the admin theme.
$this->adminUser
->addRole($this->drupalCreateRole(['view the administration theme']))
->save();
// Configure a different default and admin theme, like on most Drupal sites.
$this->config('system.theme')
->set('default', 'stable9')
->set('admin', 'starterkit_theme')
->save();
// Assert that when looking at an embedded entity in the CKEditor Widget,
// the preview is generated using the default theme, not the admin theme.
// @see media_test_embed_entity_view_alter()
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$element = $assert_session->elementExists('css', '[data-media-embed-test-active-theme]');
$this->assertSame('stable9', $element->getAttribute('data-media-embed-test-active-theme'));
// Assert that the first preview request transferred >500 B over the wire.
// Then toggle source mode on and off. This causes the CKEditor widget to be
// destroyed and then reconstructed. Assert that during this reconstruction,
// a second request is sent. This second request should have transferred 0
// bytes: the browser should have cached the response, thus resulting in a
// much better user experience.
$this->assertGreaterThan(500, $this->getLastPreviewRequestTransferSize());
$this->pressEditorButton('Source');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-source-editing-area'));
// CKEditor 5 is very smart: if no changes were made in the Source Editing
// Area, it will not rerender the contents. In this test, we
// want to verify that Media preview responses are cached on the client side
// so it is essential that rerendering occurs. To achieve this, we append a
// single space.
$source_text_area = $this->getSession()->getPage()->find('css', '[name="body[0][value]"] + .ck-editor textarea');
$source_text_area->setValue($source_text_area->getValue() . ' ');
$this->pressEditorButton('Source');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$this->assertSame(0, $this->getLastPreviewRequestTransferSize());
}
/**
* Tests preview route access.
*
* @param bool $media_embed_enabled
* Whether to test with media_embed filter enabled on the text format.
* @param bool $can_use_format
* Whether the logged in user is allowed to use the text format.
*
* @dataProvider previewAccessProvider
*/
public function testEmbedPreviewAccess($media_embed_enabled, $can_use_format): void {
// Reconfigure the host entity's text format to suit our needs.
/** @var \Drupal\filter\FilterFormatInterface $format */
$format = FilterFormat::load($this->host->body->format);
$format->set('filters', [
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
'media_embed' => ['status' => $media_embed_enabled],
]);
$format->save();
$permissions = [
'bypass node access',
];
if ($can_use_format) {
$permissions[] = $format->getPermissionName();
}
$this->drupalLogin($this->drupalCreateUser($permissions));
$this->drupalGet($this->host->toUrl('edit-form'));
$assert_session = $this->assertSession();
if ($can_use_format) {
$this->waitForEditor();
if ($media_embed_enabled) {
// The preview rendering, which in this test will use Starterkit theme's
// media.html.twig template, will fail without the CSRF token/header.
// @see ::testEmbeddedMediaPreviewWithCsrfToken()
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'article.media'));
}
else {
// If the filter isn't enabled, there won't be an error, but the
// preview shouldn't be rendered.
$assert_session->elementNotExists('css', 'article.media');
}
}
else {
$assert_session->pageTextContains('This field has been disabled because you do not have sufficient permissions to edit it.');
}
}
/**
* Data provider for ::testEmbedPreviewAccess.
*/
public static function previewAccessProvider() {
return [
'media_embed filter enabled' => [
TRUE,
TRUE,
],
'media_embed filter disabled' => [
FALSE,
TRUE,
],
'media_embed filter enabled, user not allowed to use text format' => [
TRUE,
FALSE,
],
];
}
/**
* Ensure media preview isn't clickable.
*/
public function testMediaPointerEvent(): void {
$entityViewDisplay = EntityViewDisplay::load('media.image.view_mode_1');
$thumbnail = $entityViewDisplay->getComponent('thumbnail');
$thumbnail['settings']['image_link'] = 'file';
$entityViewDisplay->setComponent('thumbnail', $thumbnail);
$entityViewDisplay->save();
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$url = $this->host->toUrl('edit-form');
$this->drupalGet($url);
$this->waitForEditor();
$assert_session->waitForLink('default alt');
$page->find('css', '.ck .drupal-media')->click();
// Assert that the media preview is not clickable by comparing the URL.
$this->assertEquals($url->toString(), $this->getUrl());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\Core\Entity\Entity\EntityViewMode;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\media\Entity\Media;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolation;
/**
* Base class for CKEditor 5 Media integration tests.
*
* @internal
*/
abstract class MediaTestBase extends WebDriverTestBase {
use CKEditor5TestTrait;
use MediaTypeCreationTrait;
use TestFileCreationTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* The sample Media entity to embed.
*
* @var \Drupal\media\MediaInterface
*/
protected $media;
/**
* The second sample Media entity to embed used in one of the tests.
*
* @var \Drupal\media\MediaInterface
*/
protected $mediaFile;
/**
* A host entity with a body field to embed media in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'media',
'node',
'text',
'media_test_embed',
'media_library',
'ckeditor5_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'starterkit_theme';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
EntityViewMode::create([
'id' => 'media.view_mode_1',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 1',
])->save();
EntityViewMode::create([
'id' => 'media.22222',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 2 has Numeric ID',
])->save();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <strong> <em> <a href> <drupal-media data-entity-type data-entity-uuid data-align data-view-mode data-caption alt>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
'media_embed' => [
'status' => TRUE,
'settings' => [
'default_view_mode' => 'view_mode_1',
'allowed_view_modes' => [
'view_mode_1' => 'view_mode_1',
'22222' => '22222',
],
'allowed_media_types' => [],
],
],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'sourceEditing',
'link',
'bold',
'italic',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'media_media' => [
'allow_view_mode_override' => TRUE,
],
],
],
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
// Note that media_install() grants 'view media' to all users by default.
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]);
// Create a sample media entity to be embedded.
$this->createMediaType('image', ['id' => 'image']);
File::create([
'uri' => $this->getTestFiles('image')[0]->uri,
])->save();
$this->media = Media::create([
'bundle' => 'image',
'name' => 'Screaming hairy armadillo',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'default alt',
'title' => 'default title',
],
],
]);
$this->media->save();
$this->createMediaType('file', ['id' => 'file']);
File::create([
'uri' => $this->getTestFiles('text')[0]->uri,
])->save();
$this->mediaFile = Media::create([
'bundle' => 'file',
'name' => 'Information about screaming hairy armadillo',
'field_media_file' => [
[
'target_id' => 2,
],
],
]);
$this->mediaFile->save();
// Set created media types for each view mode.
EntityViewDisplay::create([
'id' => 'media.image.view_mode_1',
'targetEntityType' => 'media',
'status' => TRUE,
'bundle' => 'image',
'mode' => 'view_mode_1',
])->save();
EntityViewDisplay::create([
'id' => 'media.image.22222',
'targetEntityType' => 'media',
'status' => TRUE,
'bundle' => 'image',
'mode' => '22222',
])->save();
// Create a sample host entity to embed media in.
$this->drupalCreateContentType(['type' => 'blog']);
$this->host = $this->createNode([
'type' => 'blog',
'title' => 'Animals with strange names',
'body' => [
'value' => '<drupal-media data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '" data-caption="baz"></drupal-media>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
/**
* Verifies value of an attribute on the downcast <drupal-media> element.
*
* Assumes CKEditor is in source mode.
*
* @param string $attribute
* The attribute to check.
* @param string|null $value
* Either a string value or if NULL, asserts that <drupal-media> element
* doesn't have the attribute.
*
* @internal
*/
protected function assertSourceAttributeSame(string $attribute, ?string $value): void {
$dom = $this->getEditorDataAsDom();
$drupal_media = (new \DOMXPath($dom))->query('//drupal-media');
$this->assertNotEmpty($drupal_media);
if ($value === NULL) {
$this->assertFalse($drupal_media[0]->hasAttribute($attribute));
}
else {
$this->assertSame($value, $drupal_media[0]->getAttribute($attribute));
}
}
/**
* Gets the transfer size of the last preview request.
*
* @return int
* The size of the bytes transferred.
*/
protected function getLastPreviewRequestTransferSize() {
$javascript = <<<JS
(function(){
return window.performance
.getEntries()
.filter(function (entry) {
return entry.initiatorType == 'fetch' && entry.name.indexOf('/media/test_format/preview') !== -1;
})
.pop()
.transferSize;
})()
JS;
return $this->getSession()->evaluateScript($javascript);
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolation;
// cspell:ignore sourceediting
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing
* @covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig
* @group ckeditor5
* @group #slow
* @internal
*/
class SourceEditingEmptyElementTest extends SourceEditingTestBase {
/**
* Tests creating empty inline elements using Source Editing.
*
* @testWith ["<p>Before <i class=\"fab fa-drupal\"></i> and after.</p>", "<p>Before and after.</p>", "<p>Before and after.</p>", null]
* ["<p>Before <i class=\"fab fa-drupal\"></i> and after.</p>", "<p>Before &nbsp;and after.</p>", null, "<i>"]
* ["<p>Before <i class=\"fab fa-drupal\"></i> and after.</p>", null, null, "<i class>"]
* ["<p>Before <span class=\"icon my-icon\"></span> and after.</p>", "<p>Before and after.</p>", "<p>Before and after.</p>", null]
* ["<p>Before <span class=\"icon my-icon\"></span> and after.</p>", "<p>Before &nbsp;and after.</p>", null, "<span>"]
* ["<p>Before <span class=\"icon my-icon\"></span> and after.</p>", "<p>Before <span class=\"icon\"></span> and after.</p>", null, "<span class=\"icon\">"]
*/
public function testEmptyInlineElement(string $input, ?string $expected_output_when_restricted, ?string $expected_output_when_unrestricted, ?string $allowed_elements_string): void {
$this->host->body->value = $input;
$this->host->save();
// If no expected output is specified, it should be identical to the input.
if ($expected_output_when_restricted === NULL) {
$expected_output_when_restricted = $input;
}
if ($expected_output_when_unrestricted === NULL) {
$expected_output_when_unrestricted = $input;
}
$text_editor = Editor::load('test_format');
$text_format = FilterFormat::load('test_format');
if ($allowed_elements_string) {
// Allow creating additional HTML using SourceEditing.
$settings = $text_editor->getSettings();
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'][] = $allowed_elements_string;
$text_editor->setSettings($settings);
// Keep the allowed HTML tags in sync.
$allowed_elements = HTMLRestrictions::fromTextFormat($text_format);
$updated_allowed_tags = $allowed_elements->merge(HTMLRestrictions::fromString($allowed_elements_string));
$filter_html_config = $text_format->filters('filter_html')
->getConfiguration();
$filter_html_config['settings']['allowed_html'] = $updated_allowed_tags->toFilterHtmlAllowedTagsString();
$text_format->setFilterConfig('filter_html', $filter_html_config);
// Verify the text format and editor are still a valid pair.
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
$text_editor,
$text_format
))
));
// If valid, save both.
$text_format->save();
$text_editor->save();
}
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertSame($expected_output_when_restricted, $this->getEditorDataAsHtmlString());
// Make the text format unrestricted: disable filter_html.
$text_format
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
// Verify the text format and editor are still a valid pair.
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
$text_editor,
$text_format
))
));
// Test with a text format allowing arbitrary HTML.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertSame($expected_output_when_unrestricted, $this->getEditorDataAsHtmlString());
}
}

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolation;
// cspell:ignore gramma sourceediting
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing
* @covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig
* @group ckeditor5
* @group #slow
* @internal
*/
class SourceEditingTest extends SourceEditingTestBase {
/**
* @covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::buildConfigurationForm
*/
public function testSourceEditingSettingsForm(): void {
$this->drupalLogin($this->drupalCreateUser(['administer filters']));
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// The Source Editing plugin settings form should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and should
// have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<div data-foo>';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
// Immediately save the configuration. Intentionally do nothing that would
// trigger an AJAX rebuild.
$page->pressButton('Save configuration');
// Verify that the configuration was saved.
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$page->clickLink('Source editing');
$this->assertNotNull($ghs_textarea = $assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$ghs_string = '<div data-foo>';
$this->assertSame($ghs_string, $ghs_textarea->getValue());
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertStringContainsString($ghs_string, $allowed_html_field->getValue(), "$ghs_string not found in the allowed tags value of: {$allowed_html_field->getValue()}");
}
/**
* Tests allowing extra attributes on already supported tags using GHS.
*
* @dataProvider providerAllowingExtraAttributes
*/
public function testAllowingExtraAttributes(string $original_markup, string $expected_markup, ?string $allowed_elements_string = NULL): void {
$this->host->body->value = $original_markup;
$this->host->save();
if ($allowed_elements_string) {
// Allow creating additional HTML using SourceEditing.
$text_editor = Editor::load('test_format');
$settings = $text_editor->getSettings();
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'][] = $allowed_elements_string;
$text_editor->setSettings($settings);
// Keep the allowed HTML tags in sync.
$text_format = FilterFormat::load('test_format');
$allowed_elements = HTMLRestrictions::fromTextFormat($text_format);
$updated_allowed_tags = $allowed_elements->merge(HTMLRestrictions::fromString($allowed_elements_string));
$filter_html_config = $text_format->filters('filter_html')
->getConfiguration();
$filter_html_config['settings']['allowed_html'] = $updated_allowed_tags->toFilterHtmlAllowedTagsString();
$text_format->setFilterConfig('filter_html', $filter_html_config);
// Verify the text format and editor are still a valid pair.
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
$text_editor,
$text_format
))
));
// If valid, save both.
$text_format->save();
$text_editor->save();
}
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertSame($expected_markup, $this->getEditorDataAsHtmlString());
}
/**
* Data provider for ::testAllowingExtraAttributes().
*
* @return array
* The test cases.
*/
public static function providerAllowingExtraAttributes(): array {
$general_test_case_markup = '<div class="llama" data-llama="🦙"><p data-llama="🦙">The <a href="https://example.com/pirate" class="button" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" class="use-ajax" data-grammar="adjective">irate</a>.</p></div>';
return [
'no extra attributes allowed' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
],
// Common case: any attribute that is not `style` or `class`.
'<a data-grammar="subject">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<a data-grammar="subject">',
],
'<a data-grammar="adjective">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a data-grammar="adjective">',
],
'<a data-grammar>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a data-grammar>',
],
// Edge case: `class`.
'<a class="button">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a class="button" href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<a class="button">',
],
'<a class="use-ajax">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p></div>',
'<a class="use-ajax">',
],
'<a class>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a class="button" href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p></div>',
'<a class>',
],
// Edge case: $text-container wildcard with additional
// attribute.
'<$text-container data-llama>' => [
$general_test_case_markup,
'<div class="llama" data-llama="🦙"><p data-llama="🦙">The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<$text-container data-llama>',
],
// Edge case: $text-container wildcard with stricter attribute
// constrain.
'<$text-container class="not-llama">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<$text-container class="not-llama">',
],
// Edge case: wildcard attribute names:
// - prefix, f.e. `data-*`
// - infix, f.e. `*gramma*`
// - suffix, f.e. `*-grammar`
'<a data-*>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a data-*>',
],
'<a *gramma*>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a *gramma*>',
],
'<a *-grammar>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a *-grammar>',
],
// Edge case: concrete attribute with wildcard class value.
'<a class="use-*">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p></div>',
'<a class="use-*">',
],
// Edge case: concrete attribute with wildcard attribute value.
'<a data-grammar="sub*">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<a data-grammar="sub*">',
],
// Edge case: `data-*` with wildcard attribute value.
'<a data-*="sub*">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<a data-*="sub*">',
],
// Edge case: `style`.
// @todo https://www.drupal.org/project/drupal/issues/3304832
// Edge case: `type` attribute on lists.
// @todo Remove in https://www.drupal.org/project/drupal/issues/3274635.
'no numberedList-related additions to the Source Editing configuration' => [
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol><li>foo</li><li>bar</li></ol>',
],
'<ol type>' => [
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol type>',
],
'<ol type="A">' => [
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol type="A">',
],
'no bulletedList-related additions to the Source Editing configuration' => [
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul><li>foo</li><li>bar</li></ul>',
],
'<ul type>' => [
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul type>',
],
'<ul type="circle">' => [
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul type="circle">',
],
];
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolation;
// cspell:ignore sourceediting
/**
* @internal
*/
abstract class SourceEditingTestBase extends CKEditor5TestBase {
use CKEditor5TestTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* A host entity with a body field whose text to edit with CKEditor 5.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'node',
'text',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<div class> <p> <br> <a href> <ol> <ul> <li>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'sourceEditing',
'link',
'bulletedList',
'numberedList',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => ['<div class>'],
],
'ckeditor5_list' => [
'properties' => [
'reversed' => FALSE,
'startIndex' => FALSE,
],
'multiBlock' => TRUE,
],
],
],
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]);
// Create a sample host entity to test CKEditor 5.
$this->host = $this->createNode([
'type' => 'page',
'title' => 'Animals with strange names',
'body' => [
'value' => '',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
}

View File

@@ -0,0 +1,664 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
// cspell:ignore sourceediting
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Symfony\Component\Validator\ConstraintViolation;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style
* @group ckeditor5
* @internal
*/
class StyleTest extends CKEditor5TestBase {
use CKEditor5TestTrait;
/**
* @covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style::buildConfigurationForm
*/
public function testStyleSettingsForm(): void {
$this->drupalLogin($this->drupalCreateUser(['administer filters']));
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// The Style plugin settings form should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-style'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-style', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// No validation error upon enabling the Style plugin.
$this->assertNoRealtimeValidationErrors();
$assert_session->pageTextContains('No styles configured');
// Still no validation error when configuring other functionality first.
$this->triggerKeyUp('.ckeditor5-toolbar-item-undo', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$this->assertNoRealtimeValidationErrors();
// The Style plugin settings form should now be present and should have no
// styles configured.
$page->clickLink('Style');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]');
allowedTags.value = 'p.foo.bar | Foobar paragraph';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
// Immediately save the configuration. Intentionally do nothing that would
// trigger an AJAX rebuild.
$page->pressButton('Save configuration');
$assert_session->pageTextContains('Added text format');
// Verify that the configuration was saved.
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$page->clickLink('Style');
$this->assertNotNull($styles_textarea = $assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]'));
$this->assertSame("p.foo.bar|Foobar paragraph\n", $styles_textarea->getValue());
$assert_session->pageTextContains('One style configured');
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertStringContainsString('<p class="foo bar">', $allowed_html_field->getValue());
// Attempt to use an unsupported HTML5 tag.
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]');
allowedTags.value = 's.redacted|Redacted';
allowedTags.dispatchEvent(new Event('change'));
JS;
$this->getSession()->executeScript($javascript);
// The CKEditor 5 module should refuse to specify styles on tags that cannot
// (yet) be created.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraintValidator::checkAllHtmlTagsAreCreatable()
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="error"]:contains("The Style plugin needs another plugin to create <s>, for it to be able to create the following attributes: <s class="redacted">. Enable a plugin that supports creating this tag. If none exists, you can configure the Source Editing plugin to support it.")');
// The entire vertical tab for "Style" settings should be marked up as the
// cause of the error, which means the "Styles" text area in there is marked
// too.
$assert_session->elementExists('css', '.vertical-tabs__pane[data-ckeditor5-plugin-id="ckeditor5_style"][aria-invalid="true"]');
$assert_session->elementExists('css', '.vertical-tabs__pane[data-ckeditor5-plugin-id="ckeditor5_style"] textarea[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"][aria-invalid="true"]');
// Attempt to save anyway: the warning should become an error.
$page->pressButton('Save configuration');
$assert_session->pageTextNotContains('Added text format');
$assert_session->elementExists('css', '[aria-label="Error message"]:contains("The Style plugin needs another plugin to create <s>, for it to be able to create the following attributes: <s class="redacted">. Enable a plugin that supports creating this tag. If none exists, you can configure the Source Editing plugin to support it.")');
// Now, attempt to use a supported non-HTML5 tag.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\StyleSensibleElementConstraintValidator
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]');
allowedTags.value = 'drupal-media.sensational|Sensational media';
allowedTags.dispatchEvent(new Event('change'));
JS;
$this->getSession()->executeScript($javascript);
// The CKEditor 5 module should refuse to allow styles on non-HTML5 tags.
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="error"]:contains("A style can only be specified for an HTML 5 tag. <drupal-media> is not an HTML5 tag.")');
// The vertical tab for "Style" settings should not be marked up as the cause
// of the error, but only the "Styles" text area in the vertical tab.
$assert_session->elementNotExists('css', '.vertical-tabs__pane[data-ckeditor5-plugin-id="ckeditor5_style"][aria-invalid="true"]');
$assert_session->elementExists('css', '.vertical-tabs__pane[data-ckeditor5-plugin-id="ckeditor5_style"] textarea[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"][aria-invalid="true"]');
// Test configuration overlaps across plugins.
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$this->assertNotEmpty($assert_session->elementExists('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and should
// have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
// Make `<aside class>` creatable.
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<aside class>';
allowedTags.dispatchEvent(new Event('change'));
JS;
$this->getSession()->executeScript($javascript);
$assert_session->assertWaitOnAjaxRequest();
// Create a style with `aside` and a class name.
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]');
allowedTags.value = 'aside.error|Aside';
allowedTags.dispatchEvent(new Event('change'));
JS;
$this->getSession()->executeScript($javascript);
$assert_session->assertWaitOnAjaxRequest();
// The CKEditor 5 module should refuse to create configuration overlaps
// across plugins.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\StyleSensibleElementConstraintValidator::findStyleConflictingPluginLabel()
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="error"]:contains("A style must only specify classes not supported by other plugins.")');
}
/**
* Tests Style functionality: setting a class, expected style choices.
*/
public function testStyleFunctionality(): void {
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p class="highlighted interesting"> <br> <a href class="reliable"> <blockquote class="famous"> <h2 class="red-heading"> <ul class="items"> <ol class="steps"> <li> <table class="data-analysis"> <tr> <td rowspan colspan> <th rowspan colspan> <thead> <tbody> <tfoot> <caption class="caution"> <div class="deep-dive">',
],
],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'heading',
'link',
'blockQuote',
'style',
'bulletedList',
'numberedList',
'insertTable',
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_heading' => [
'enabled_headings' => [
'heading2',
],
],
'ckeditor5_list' => [
'properties' => [
'reversed' => FALSE,
'startIndex' => FALSE,
],
'multiBlock' => TRUE,
],
'ckeditor5_sourceEditing' => [
'allowed_tags' => [
'<div>',
],
],
'ckeditor5_style' => [
'styles' => [
[
'label' => 'Highlighted & interesting',
'element' => '<p class="highlighted interesting">',
],
[
'label' => 'Red heading',
'element' => '<h2 class="red-heading">',
],
[
'label' => 'Reliable source',
'element' => '<a class="reliable">',
],
[
'label' => 'Famous',
'element' => '<blockquote class="famous">',
],
[
'label' => 'Items',
'element' => '<ul class="items">',
],
[
'label' => 'Steps',
'element' => '<ol class="steps">',
],
[
'label' => 'Data analysis',
'element' => '<table class="data-analysis">',
],
[
'label' => 'Truly deep dive',
'element' => '<div class="deep-dive">',
],
[
'label' => 'Caution caption',
'element' => '<caption class="caution">',
],
],
],
],
],
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
// Create a sample entity to test CKEditor 5.
$node = $this->createNode([
'type' => 'page',
'title' => 'A selection of the history of Drupal',
'body' => [
'value' => '<h2>Upgrades</h2><p class="history">Drupal has historically been difficult to upgrade from one major version to the next.</p><p class="highlighted interesting">This changed with Drupal 8.</p><blockquote class="famous"><p>Updating from Drupal 8\'s latest version to Drupal 9.0.0 should be as easy as updating between minor versions of Drupal 8.</p></blockquote><p> — <a href="https://dri.es/making-drupal-upgrades-easy-forever">Dries</a></p><div><ul><li>Update Drupal core using Composer</li><li>Update Drupal core manually</li><li>Update Drupal core using Drush</li></ul><ol><li>Back up your files and database</li><li>Put your site into maintenance mode</li><li>Update the code and apply changes</li><li>Deactivate maintenance mode</li></ol><table><caption>Drupal upgrades are now easy, with a few caveats.</caption><tbody><tr><td>First</td><td>Second</td></tr><tr><td>Data value 1</td><td>Data value 2</td></tr></tbody></table></div>',
'format' => 'test_format',
],
]);
$node->save();
// Observe.
$this->drupalLogin($this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]));
// Set a taller window size to ensure all possible style choices are in view
// because otherwise Mink's getText() will return the empty string for those
// out of view, despite the HTML showing that text.
$this->getSession()->resizeWindow(1024, 1000);
$this->drupalGet($node->toUrl('edit-form'));
$this->waitForEditor();
// Select the <h2>, assert that no style is active currently.
$this->selectTextInsideElement('h2');
$assert_session = $this->assertSession();
$style_dropdown = $assert_session->elementExists('css', '.ck-style-dropdown');
$this->assertSame('Styles', $style_dropdown->getText());
// Click the dropdown, check the available styles.
$style_dropdown->click();
$buttons = $style_dropdown->findAll('css', '.ck-dropdown__panel button');
$this->assertCount(9, $buttons);
$this->assertSame('Highlighted & interesting', $buttons[0]->find('css', '.ck-button__label')->getText());
$this->assertSame('Red heading', $buttons[1]->find('css', '.ck-button__label')->getText());
$this->assertSame('Famous', $buttons[2]->find('css', '.ck-button__label')->getText());
$this->assertSame('Items', $buttons[3]->find('css', '.ck-button__label')->getText());
$this->assertSame('Steps', $buttons[4]->find('css', '.ck-button__label')->getText());
$this->assertSame('Data analysis', $buttons[5]->find('css', '.ck-button__label')->getText());
$this->assertSame('Truly deep dive', $buttons[6]->find('css', '.ck-button__label')->getText());
$this->assertSame('Caution caption', $buttons[7]->find('css', '.ck-button__label')->getText());
// CKEditor's Style plugin first shows all block styles.
for ($i = 0; $i <= 7; $i++) {
$style_group = $buttons[$i]->getParent()->getParent();
$this->assertTrue($style_group->hasClass('ck-style-panel__style-group'));
$this->assertSame('Block styles', $style_group->find('css', 'label')->getText());
}
// And then all text styles.
for ($i = 8; $i <= 8; $i++) {
$style_group = $buttons[$i]->getParent()->getParent();
$this->assertTrue($style_group->hasClass('ck-style-panel__style-group'));
$this->assertSame('Text styles', $style_group->find('css', 'label')->getText());
}
$this->assertSame('Reliable source', $buttons[8]->find('css', '.ck-button__label')->getText());
$this->assertSame('true', $buttons[0]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[1]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
// Apply the "Red heading" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main h2:not(.red-heading)');
$buttons[1]->click();
$assert_session->elementExists('css', '.ck-editor__main h2.red-heading');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-on'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Red heading', $style_dropdown->getText());
// Select the first paragraph and observe changes in:
// - styles dropdown label
// - button states
$this->selectTextInsideElement('p');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Close the dropdown.
$style_dropdown->click();
// Select the blockquote and observe changes in:
// - styles dropdown label
// - button states
$this->selectTextInsideElement('blockquote');
$this->assertSame('Famous', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-on'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
// TRICKY: the blockquote contains a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[2]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Close the dropdown.
$style_dropdown->click();
// Select the <ul> and check the available styles
$this->selectTextInsideElement('ul');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the contents of the list item can be converted to a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[3]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
// TRICKY: the <ul> is wrapped in a <div>, so the "Truly deep dive" <div>
// style is available!
$this->assertFalse($buttons[6]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Items" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main ul:not(.items)');
$buttons[3]->click();
$assert_session->elementExists('css', '.ck-editor__main ul.items');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-on'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Items', $style_dropdown->getText());
// Select the <ol> and check the available styles
$this->selectTextInsideElement('ol');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the contents of the list item can be converted to a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[4]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
// TRICKY: the <ol> is wrapped in a <div>, so the "Truly deep dive" <div>
// style is available!
$this->assertFalse($buttons[6]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Steps" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main ol:not(.steps)');
$buttons[4]->click();
$assert_session->elementExists('css', '.ck-editor__main ol.steps');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-on'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Steps', $style_dropdown->getText());
// Select the table and check the available styles
$this->selectTextInsideElement('table td');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the contents of the table cell can be converted to a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[5]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Data analysis" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main table:not(.data-analysis)');
$buttons[5]->click();
$assert_session->elementExists('css', '.ck-editor__main table.data-analysis');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-on'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Data analysis', $style_dropdown->getText());
// Select the link, assert that no style is active currently.
$this->selectTextInsideElement('a');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the link is inside a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[8]->hasAttribute('aria-disabled'));
// Apply the "Reliable source" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main a:not(.reliable)');
$buttons[8]->click();
$assert_session->elementExists('css', '.ck-editor__main a.reliable');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-on'));
$this->assertSame('Reliable source', $style_dropdown->getText());
// Because we cannot select the <div> directly (it's not a visible element),
// select the <ol> AGAIN and check the available styles — because we should
// be able to change the containing <div>'s style through here. Note that we
// already activated the "Steps" style previously.
$this->selectTextInsideElement('ol');
$this->assertSame('Steps', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-on'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the contents of the list item can be converted to a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[4]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
// TRICKY: the <ol> is wrapped in a <div>, so the "Truly deep dive" <div>
// style is available!
$this->assertFalse($buttons[6]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Truly deep dive" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main div:not(.deep-dive)');
$buttons[6]->click();
$assert_session->elementExists('css', '.ck-editor__main div.deep-dive');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-on'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-on'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Multiple styles', $style_dropdown->getText());
// Select the table caption, assert that no style is active currently.
$this->selectTextInsideElement('figure.table > figcaption');
$this->assertSame('Data analysis', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
// TRICKY: the caption is inside the table, so the "Data analysis" style is
// also active.
$this->assertTrue($buttons[5]->hasClass('ck-on'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('true', $buttons[0]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
// TRICKY: the caption is inside the table, so the "Data analysis" style is
// also active.
$this->assertFalse($buttons[5]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[7]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Caution caption" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main figure.table > figcaption:not(.caution)');
$buttons[7]->click();
$assert_session->elementExists('css', '.ck-editor__main figure.table > figcaption.caution');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-on'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-on'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Multiple styles', $style_dropdown->getText());
// The resulting markup should be identical to the starting markup, with
// seven changes:
// 1. the `red-heading` class has been added to the `<h2>`
// 2. the `history` class has been removed from the `<p>`, because CKEditor
// 5 has not been configured for this: if a Style had configured for it,
// it would have been retained.
// 3. the `items` class has been added to the `<ul>`
// 4. the `steps` class has been added to the `<ol>`
// 5. the `data-analysis` class has been added to the `<table>`
// 6. the `reliable` class has been added to the `<a>`
// 7. The `deep-dive` class has been added to the `<div>`
// 8. The `caution` class has been added to the `<caption>`
$this->assertSame('<h2 class="red-heading">Upgrades</h2><p>Drupal has historically been difficult to upgrade from one major version to the next.</p><p class="highlighted interesting">This changed with Drupal 8.</p><blockquote class="famous"><p>Updating from Drupal 8\'s latest version to Drupal 9.0.0 should be as easy as updating between minor versions of Drupal 8.</p></blockquote><p>— <a class="reliable" href="https://dri.es/making-drupal-upgrades-easy-forever">Dries</a></p><div class="deep-dive"><ul class="items"><li>Update Drupal core using Composer</li><li>Update Drupal core manually</li><li>Update Drupal core using Drush</li></ul><ol class="steps"><li>Back up your files and database</li><li>Put your site into maintenance mode</li><li>Update the code and apply changes</li><li>Deactivate maintenance mode</li></ol><table class="data-analysis"><caption class="caution">Drupal upgrades are now easy, with a few caveats.</caption><tbody><tr><td>First</td><td>Second</td></tr><tr><td>Data value 1</td><td>Data value 2</td></tr></tbody></table></div>', $this->getEditorDataAsHtmlString());
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Symfony\Component\Validator\ConstraintViolation;
/**
* For testing the table plugin.
*
* @group ckeditor5
* @internal
*/
class TableTest extends WebDriverTestBase {
use CKEditor5TestTrait;
/**
* A host entity with a body field to embed images in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* Text added to captions.
*
* @var string
*/
protected $captionText = 'some caption';
/**
* Text added to table cells.
*
* @var string
*/
protected $tableCellText = 'table cell';
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'node',
'text',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page']);
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<br> <p> <table> <tr> <td rowspan colspan> <th rowspan colspan> <thead> <tbody> <tfoot> <caption>',
],
],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'insertTable',
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
// Create a sample host entity.
$this->host = $this->createNode([
'type' => 'page',
'title' => 'Animals with strange names',
'body' => [
'value' => '<p>some content that will likely change</p>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]));
}
/**
* Confirms tables convert to the expected markup.
*/
public function testTableConversion(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
// This is CKEditor 5's default table markup, but uses elements that are
// not allowed by the text format.
$this->host->body->value = '<figure class="table"><table><tbody><tr><td>table cell</td></tr></tbody></table> <figcaption>some caption</figcaption></figure>';
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->captionText = 'some caption';
$this->tableCellText = 'table cell';
$table_container = $assert_session->waitForElementVisible('css', 'figure.table');
$this->assertNotNull($table_container);
$caption = $page->find('css', 'figure.table > figcaption');
$this->assertEquals($this->captionText, $caption->getText());
$table = $page->find('css', 'figure.table > table');
$this->assertEquals($this->tableCellText, $table->getText());
$this->assertTableStructureInEditorData();
$this->assertTableStructureInRenderedPage();
}
/**
* Tests creating a table with caption in the UI.
*/
public function testTableCaptionUi(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Add a table via the editor buttons.
$table_button = $page->find('css', '.ck-dropdown button');
$table_button->click();
// Add a single table cell.
$grid_button = $assert_session->waitForElementVisible('css', '.ck-insert-table-dropdown-grid-box[data-row="1"][data-column="1"]');
$grid_button->click();
// Confirm the table has been added and no caption is present.
$this->assertNotNull($table_figure = $assert_session->waitForElementVisible('css', 'figure.table'));
$assert_session->elementNotExists('css', 'figure.table > figcaption');
// Enable captions and update caption content.
$caption_button = $this->getBalloonButton('Toggle caption on');
$caption_button->click();
$caption = $assert_session->waitForElementVisible('css', 'figure.table > figcaption');
$this->assertEmpty($caption->getText());
$caption->setValue($this->captionText);
$this->assertEquals($this->captionText, $caption->getText());
// Update table cell content.
$table_cell = $assert_session->waitForElement('css', '.ck-editor__nested-editable .ck-table-bogus-paragraph');
$this->assertNotEmpty($table_cell);
$table_cell->click();
$table_cell->setValue($this->tableCellText);
$table_cell = $page->find('css', 'figure.table > table > tbody > tr > td');
$this->assertEquals($this->tableCellText, $table_cell->getText());
$this->assertTableStructureInEditorData();
// Disable caption, confirm the caption is no longer present.
$table_figure->click();
$caption_off_button = $this->getBalloonButton('Toggle caption off');
$caption_off_button->click();
$assert_session->assertNoElementAfterWait('css', 'figure.table > figcaption');
// Re-enable caption and confirm the value was retained.
$table_figure->click();
$caption_on_button = $this->getBalloonButton('Toggle caption on');
$caption_on_button->click();
$caption = $assert_session->waitForElementVisible('css', 'figure.table > figcaption');
$this->assertEquals($this->captionText, $caption->getText());
$this->assertTableStructureInRenderedPage();
}
/**
* Confirms the structure of the table within the editor data.
*/
public function assertTableStructureInEditorData(): void {
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertEmpty($xpath->query('//figure'), 'There should be no figure tag in editor data');
$this->assertNotEmpty($xpath->query('//table/caption'), 'A caption should be the immediate child of <table>');
$this->assertEquals($this->captionText, (string) $xpath->query('//table/caption')[0]->nodeValue, "The caption should say {$this->captionText}");
$this->assertNotEmpty($xpath->query('//table/tbody/tr/td'), 'There is an expected table structure.');
$this->assertEquals($this->tableCellText, (string) $xpath->query('//table/tbody/tr/td')[0]->nodeValue, "The table cell should say {$this->tableCellText}");
}
/**
* Confirms the saved page has the expected table structure.
*/
public function assertTableStructureInRenderedPage(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$page->pressButton('Save');
$assert_session->waitForText('has been updated');
$assert_session->pageTextContains($this->tableCellText);
$assert_session->pageTextContains($this->captionText);
$assert_session->elementNotExists('css', 'figure');
$this->assertNotNull($table_cell = $page->find('css', 'table > tbody > tr > td'), 'Table on rendered page has expected structure');
$this->assertEquals($this->tableCellText, $table_cell->getText(), 'Table on rendered page has expected content');
$this->assertNotNull($table_caption = $page->find('css', 'table > caption '), 'Table caption is in expected structure.');
$this->assertEquals($this->captionText, $table_caption->getText(), 'Table caption has expected text');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Test the ckeditor5-stylesheets theme config property.
*
* @group ckeditor5
*/
class CKEditor5StylesheetsTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'system',
'user',
'ckeditor5',
'editor',
'filter',
];
/**
* Tests loading of theme's CKEditor 5 stylesheets defined in the .info file.
*
* @param string $theme
* The machine name of the theme.
* @param array $expected
* The expected CKEditor 5 CSS paths from the theme.
*
* @dataProvider externalStylesheetsProvider
*/
public function testExternalStylesheets($theme, $expected): void {
\Drupal::service('theme_installer')->install([$theme]);
$this->config('system.theme')->set('default', $theme)->save();
$this->assertSame($expected, _ckeditor5_theme_css($theme));
}
/**
* Provides test cases for external stylesheets.
*
* @return array
* An array of test cases.
*/
public static function externalStylesheetsProvider() {
return [
'Install theme which has an absolute external CSS URL' => [
'test_ckeditor_stylesheets_external',
['https://fonts.googleapis.com/css?family=Open+Sans'],
],
'Install theme which has an external protocol-relative CSS URL' => [
'test_ckeditor_stylesheets_protocol_relative',
['//fonts.googleapis.com/css?family=Open+Sans'],
],
'Install theme which has a relative CSS URL' => [
'test_ckeditor_stylesheets_relative',
['/core/modules/system/tests/themes/test_ckeditor_stylesheets_relative/css/yokotsoko.css'],
],
'Install theme which has a Drupal root CSS URL' => [
'test_ckeditor_stylesheets_drupal_root',
['/core/modules/system/tests/themes/test_ckeditor_stylesheets_drupal_root/css/yokotsoko.css'],
],
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\EditorInterface;
use Drupal\filter\FilterFormatInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Defines a trait for testing CKEditor 5 validity.
*/
trait CKEditor5ValidationTestTrait {
/**
* Decorator for CKEditor5::validatePair() that returns an assertable array.
*
* @param \Drupal\editor\EditorInterface $text_editor
* The paired text editor to validate.
* @param \Drupal\filter\FilterFormatInterface $text_format
* The paired text format to validate.
* @param bool $all_compatibility_problems
* Only fundamental compatibility violations are returned unless TRUE.
*
* @return array
* An array with property paths as keys and violation messages as values.
*
* @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::validatePair
*/
private function validatePairToViolationsArray(EditorInterface $text_editor, FilterFormatInterface $text_format, bool $all_compatibility_problems): array {
$violations = CKEditor5::validatePair($text_editor, $text_format, $all_compatibility_problems);
return self::violationsToArray($violations);
}
/**
* Transforms a constraint violation list object to an assertable array.
*
* @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations
* Validation constraint violations.
*
* @return array
* An array with property paths as keys and violation messages as values.
*/
private static function violationsToArray(ConstraintViolationListInterface $violations): array {
$actual_violations = [];
foreach ($violations as $violation) {
if (!isset($actual_violations[$violation->getPropertyPath()])) {
$actual_violations[$violation->getPropertyPath()] = (string) $violation->getMessage();
}
else {
// Transform value from string to array.
if (is_string($actual_violations[$violation->getPropertyPath()])) {
$actual_violations[$violation->getPropertyPath()] = (array) $actual_violations[$violation->getPropertyPath()];
}
// And append.
$actual_violations[$violation->getPropertyPath()][] = (string) $violation->getMessage();
}
}
return $actual_violations;
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Recipe\InvalidConfigException;
use Drupal\Core\Recipe\RecipeRunner;
use Drupal\editor\Entity\Editor;
use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
use Drupal\KernelTests\KernelTestBase;
/**
* @covers \Drupal\ckeditor5\Plugin\ConfigAction\AddItemToToolbar
* @group ckeditor5
* @group Recipe
*/
class AddItemToToolbarConfigActionTest extends KernelTestBase {
use RecipeTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'editor',
'filter',
'filter_test',
'user',
];
/**
* {@inheritdoc}
*/
protected static $configSchemaCheckerExclusions = [
// This test must be allowed to save invalid config, we can confirm that
// any invalid stuff is validated by the config actions system.
'editor.editor.filter_test',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig('filter_test');
$editor = Editor::create([
'editor' => 'ckeditor5',
'format' => 'filter_test',
'image_upload' => ['status' => FALSE],
]);
$editor->save();
/** @var array{toolbar: array{items: array<int, string>}} $settings */
$settings = Editor::load('filter_test')?->getSettings();
$this->assertSame(['heading', 'bold', 'italic'], $settings['toolbar']['items']);
}
/**
* @param string|array<string, mixed> $action
* The value to pass to the config action.
* @param string[] $expected_toolbar_items
* The items which should be in the editor toolbar, in the expected order.
*
* @testWith ["sourceEditing", ["heading", "bold", "italic", "sourceEditing"]]
* [{"item_name": "sourceEditing"}, ["heading", "bold", "italic", "sourceEditing"]]
* [{"item_name": "sourceEditing", "position": 1}, ["heading", "sourceEditing", "bold", "italic"]]
* [{"item_name": "sourceEditing", "position": 1, "replace": true}, ["heading", "sourceEditing", "italic"]]
*/
public function testAddItemToToolbar(string|array $action, array $expected_toolbar_items): void {
$recipe = $this->createRecipe([
'name' => 'CKEditor 5 toolbar item test',
'config' => [
'actions' => [
'editor.editor.filter_test' => [
'addItemToToolbar' => $action,
],
],
],
]);
RecipeRunner::processRecipe($recipe);
/** @var array{toolbar: array{items: string[]}, plugins: array<string, array<mixed>>} $settings */
$settings = Editor::load('filter_test')?->getSettings();
$this->assertSame($expected_toolbar_items, $settings['toolbar']['items']);
// The plugin's default settings should have been added.
$this->assertSame([], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
}
public function testAddNonExistentItem(): void {
$recipe = $this->createRecipe([
'name' => 'Add an invalid toolbar item',
'config' => [
'actions' => [
'editor.editor.filter_test' => [
'addItemToToolbar' => 'bogus_item',
],
],
],
]);
$this->expectException(InvalidConfigException::class);
$this->expectExceptionMessage("There were validation errors in editor.editor.filter_test:\n- settings.toolbar.items.3: The provided toolbar item <em class=\"placeholder\">bogus_item</em> is not valid.");
RecipeRunner::processRecipe($recipe);
}
public function testActionRequiresCKEditor5(): void {
$this->enableModules(['editor_test']);
Editor::load('filter_test')?->setEditor('unicorn')->setSettings([])->save();
$recipe = <<<YAML
name: Not a CKEditor
config:
actions:
editor.editor.filter_test:
addItemToToolbar: strikethrough
YAML;
$this->expectException(ConfigActionException::class);
$this->expectExceptionMessage('The editor:addItemToToolbar config action only works with editors that use CKEditor 5.');
RecipeRunner::processRecipe($this->createRecipe($recipe));
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests configurable plugins.
*
* @group ckeditor5
* @internal
*/
class ConfigurablePluginTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
// These modules must be installed for ckeditor5_config_schema_info_alter()
// to work, which in turn is necessary for the plugin definition validation
// logic.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateDrupalAspects()
'filter',
'editor',
];
/**
* The manager for "CKEditor 5 plugin" plugins.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $manager;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->manager = $this->container->get('plugin.manager.ckeditor5.plugin');
}
/**
* Tests default settings for configurable CKEditor 5 plugins.
*/
public function testDefaults(): void {
$all_definitions = $this->manager->getDefinitions();
$configurable_definitions = array_filter($all_definitions, function (CKEditor5PluginDefinition $definition): bool {
return $definition->isConfigurable();
});
$default_plugin_settings = [];
foreach (array_keys($configurable_definitions) as $plugin_name) {
$default_plugin_settings[$plugin_name] = $this->manager->getPlugin($plugin_name, NULL)->defaultConfiguration();
}
$expected_default_plugin_settings = [
'ckeditor5_heading' => [
'enabled_headings' => [
'heading2',
'heading3',
'heading4',
'heading5',
'heading6',
],
],
'ckeditor5_style' => [
'styles' => [],
],
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'ckeditor5_codeBlock' => [
'languages' => [
['language' => 'plaintext', 'label' => 'Plain text'],
['language' => 'c', 'label' => 'C'],
['language' => 'cs', 'label' => 'C#'],
['language' => 'cpp', 'label' => 'C++'],
['language' => 'css', 'label' => 'CSS'],
['language' => 'diff', 'label' => 'Diff'],
['language' => 'html', 'label' => 'HTML'],
['language' => 'java', 'label' => 'Java'],
['language' => 'javascript', 'label' => 'JavaScript'],
['language' => 'php', 'label' => 'PHP'],
['language' => 'python', 'label' => 'Python'],
['language' => 'ruby', 'label' => 'Ruby'],
['language' => 'typescript', 'label' => 'TypeScript'],
['language' => 'xml', 'label' => 'XML'],
],
],
'ckeditor5_list' => [
'properties' => [
'reversed' => TRUE,
'startIndex' => TRUE,
],
'multiBlock' => TRUE,
],
'ckeditor5_alignment' => [
'enabled_alignments' => [
0 => 'left',
1 => 'center',
2 => 'right',
3 => 'justify',
],
],
'ckeditor5_image' => [],
'ckeditor5_imageResize' => [
'allow_resize' => TRUE,
],
'ckeditor5_language' => [
'language_list' => 'un',
],
];
$this->assertSame($expected_default_plugin_settings, $default_plugin_settings);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\KernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\TestTools\Random;
use Symfony\Component\Yaml\Yaml;
/**
* Tests language resolving for CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class LanguageTest extends KernelTestBase {
/**
* The CKEditor 5 plugin.
*
* @var \Drupal\ckeditor5\Plugin\Editor\CKEditor5
*/
protected $ckeditor5;
/**
* {@inheritdoc}
*/
protected static $modules = [
'system',
'user',
'ckeditor5',
'editor',
'filter',
'language',
'locale',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->ckeditor5 = $this->container->get('plugin.manager.editor')->createInstance('ckeditor5');
FilterFormat::create(
Yaml::parseFile('core/profiles/standard/config/install/filter.format.basic_html.yml')
)->save();
Editor::create([
'format' => 'basic_html',
'editor' => 'ckeditor5',
])->save();
$this->installConfig(['language']);
}
/**
* Ensure that languages are resolved correctly.
*
* @param string $drupal_langcode
* The language code in Drupal.
* @param string $cke5_langcode
* The language code in CKEditor 5.
* @param bool $is_missing_mapping
* Whether this mapping is expected to be missing from language.mappings.
*
* @dataProvider provider
*/
public function test(string $drupal_langcode, string $cke5_langcode, bool $is_missing_mapping = FALSE): void {
$editor = Editor::load('basic_html');
ConfigurableLanguage::createFromLangcode($drupal_langcode)->save();
$this->config('system.site')->set('default_langcode', $drupal_langcode)->save();
if ($is_missing_mapping) {
// CKEditor 5's UI language falls back to English, until the language
// mapping is expanded.
$settings = $this->ckeditor5->getJSSettings($editor);
$this->assertSame('en', $settings['language']['ui']);
// Expand the language mapping.
$config = $this->config('language.mappings');
$mapping = $config->get('map');
$mapping += [$cke5_langcode => $drupal_langcode];
$config->set('map', $mapping)->save();
}
$settings = $this->ckeditor5->getJSSettings($editor);
$this->assertSame($cke5_langcode, $settings['language']['ui']);
}
/**
* Provides a list of language code pairs.
*
* @return string[][]
*/
public static function provider(): array {
$random_langcode = Random::machineName();
return [
'Language code transformed from browser mappings' => [
'drupal_langcode' => 'pt-pt',
'cke5_langcode' => 'pt',
],
'Language code transformed from browser mappings 2' => [
'drupal_langcode' => 'zh-hans',
'cke5_langcode' => 'zh-cn',
],
'Language code both in Drupal and CKEditor' => [
'drupal_langcode' => 'fi',
'cke5_langcode' => 'fi',
],
'Language code not in Drupal but in CKEditor 5 requires new language.mappings entry' => [
'drupal_langcode' => $random_langcode,
'cke5_langcode' => 'de-ch',
'is_missing_mapping' => TRUE,
],
];
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\KernelTestBase;
use Symfony\Component\Validator\ConstraintViolation;
/**
* @covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig
* @group ckeditor5
* @internal
*/
class WildcardHtmlSupportTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'filter',
'editor',
];
/**
* The manager for "CKEditor 5 plugin" plugins.
*
* @var \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
*/
protected $manager;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->manager = $this->container->get('plugin.manager.ckeditor5.plugin');
}
/**
* @covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::getDynamicPluginConfig
* @dataProvider providerGhsConfiguration
*/
public function testGhsConfiguration(string $filter_html_allowed, array $source_editing_tags, array $expected_ghs_configuration, ?array $additional_toolbar_items = []): void {
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => $filter_html_allowed,
],
],
],
])->save();
$editor_config = [
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => array_merge(['sourceEditing'], $additional_toolbar_items),
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => $source_editing_tags,
],
],
],
'image_upload' => [
'status' => FALSE,
],
];
if (in_array('alignment', $additional_toolbar_items, TRUE)) {
$editor_config['settings']['plugins']['ckeditor5_alignment'] = [
'enabled_alignments' => ['left', 'center', 'right', 'justify'],
];
}
$editor = Editor::create($editor_config);
$editor->save();
$this->assertSame([], array_map(
function (ConstraintViolation $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$config = $this->manager->getCKEditor5PluginConfig($editor);
$ghs_configuration = $config['config']['htmlSupport']['allow'];
// The first two entries in the GHS configuration are from the
// `ckeditor5_globalAttributeDir` and `ckeditor5_globalAttributeLang`
// plugins. They are out of scope for this test, so omit them.
$ghs_configuration = array_slice($ghs_configuration, 2);
$this->assertEquals($expected_ghs_configuration, $ghs_configuration);
}
public static function providerGhsConfiguration(): array {
return [
'empty source editing' => [
'<p> <br>',
[],
[],
],
'without wildcard' => [
'<p> <br> <a href> <blockquote> <div data-llama>',
['<div data-llama>'],
[
[
'name' => 'div',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
],
],
['link', 'blockQuote'],
],
'<$text-container> minimal configuration' => [
'<p data-llama> <br>',
['<$text-container data-llama>'],
[
[
'name' => 'p',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
],
],
],
'<$text-container> from multiple plugins' => [
'<p data-llama class="text-align-left text-align-center text-align-right text-align-justify"> <br>',
['<$text-container data-llama>'],
[
[
'name' => 'p',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
'classes' => [
'regexp' => [
'pattern' => '/^(text-align-left|text-align-center|text-align-right|text-align-justify)$/',
],
],
],
],
['alignment'],
],
'<$text-container> with attribute from multiple plugins' => [
'<p data-llama class> <br>',
['<$text-container data-llama>', '<p class>'],
[
[
'name' => 'p',
'classes' => TRUE,
],
[
'name' => 'p',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
'classes' => [
'regexp' => [
'pattern' => '/^(text-align-left|text-align-center|text-align-right|text-align-justify)$/',
],
],
],
],
['alignment'],
],
'<$text-container> realistic configuration' => [
'<p data-llama> <br> <a href> <blockquote> <div data-llama> <mark> <abbr title>',
['<$text-container data-llama>', '<div>', '<mark>', '<abbr title>'],
[
[
'name' => 'div',
],
[
'name' => 'mark',
],
[
'name' => 'abbr',
'attributes' => [
[
'key' => 'title',
'value' => TRUE,
],
],
],
[
'name' => 'p',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
],
[
'name' => 'div',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
],
],
['link', 'blockQuote'],
],
];
}
}

View File

@@ -0,0 +1,156 @@
// cspell:ignore sourceediting
module.exports = {
'@tags': ['core', 'ckeditor5'],
before(browser) {
browser.drupalInstall({ installProfile: 'minimal' });
},
after(browser) {
browser.drupalUninstall();
},
'Verify code block configured languages are respected': (browser) => {
browser.drupalLoginAsAdmin(() => {
browser
// Enable required modules.
.drupalRelativeURL('/admin/modules')
.click('[name="modules[ckeditor5][enable]"]')
.click('[name="modules[field_ui][enable]"]')
.submitForm('input[type="submit"]') // Submit module form.
.waitForElementVisible(
'.system-modules-confirm-form input[value="Continue"]',
)
.submitForm('input[value="Continue"]') // Confirm installation of dependencies.
.waitForElementVisible('.system-modules', 10000)
// Create new input format.
.drupalRelativeURL('/admin/config/content/formats/add')
.waitForElementVisible('[data-drupal-selector="edit-name"]')
.updateValue('[data-drupal-selector="edit-name"]', 'test')
.waitForElementVisible('#edit-name-machine-name-suffix')
.click(
'[data-drupal-selector="edit-editor-editor"] option[value=ckeditor5]',
)
// Wait for CKEditor 5 settings to be visible.
.waitForElementVisible(
'[data-drupal-selector="edit-editor-settings-toolbar"]',
)
.click('.ckeditor5-toolbar-button-sourceEditing') // Select the Source Editing button.
// Hit the down arrow key to move it to the toolbar.
.perform(function () {
return this.actions().sendKeys(browser.Keys.ARROW_DOWN);
})
// Wait for new source editing vertical tab to be present before continuing.
.waitForElementVisible(
'[href*=edit-editor-settings-plugins-ckeditor5-sourceediting]',
)
.click('.ckeditor5-toolbar-item-codeBlock') // Select the Code Block button.
// Hit the down arrow key to move it to the toolbar.
.perform(function () {
return this.actions().sendKeys(browser.Keys.ARROW_DOWN);
})
// Wait for new code editing vertical tab to be present before continuing.
.waitForElementVisible(
'[href*=edit-editor-settings-plugins-ckeditor5-codeblock]',
)
.click('[href*=edit-editor-settings-plugins-ckeditor5-codeblock]')
.setValue(
'[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-codeblock-languages"]',
'twig|Twig\nyml|YML',
)
.submitForm('input[type="submit"]')
.waitForElementVisible('[data-drupal-messages]')
.assert.textContains('[data-drupal-messages]', 'Added text format')
// Create a new content type.
.drupalRelativeURL('/admin/structure/types/add')
.waitForElementVisible('[data-drupal-selector="edit-name"]')
.updateValue('[data-drupal-selector="edit-name"]', 'test')
.waitForElementVisible('#edit-name-machine-name-suffix') // Wait for machine name to update.
.submitForm('input[type="submit"]')
.waitForElementVisible('[data-drupal-messages]')
.assert.textContains(
'[data-drupal-messages]',
'The content type test has been added',
)
// Navigate to create new content.
.drupalRelativeURL('/node/add/test')
.waitForElementVisible('.ck-editor__editable')
// Open code block dropdown, and verify that correct languages are present.
.click(
'.ck-code-block-dropdown .ck-dropdown__button .ck-splitbutton__arrow',
)
.assert.textContains(
'.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item:nth-child(1) .ck-button__label',
'Twig',
)
.assert.textContains(
'.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item:nth-child(2) .ck-button__label',
'YML',
)
// Click the first language (which should be 'Twig').
.click(
'.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item:nth-child(1) button',
)
.waitForElementVisible('.ck-editor__main pre[data-language="Twig"]')
// Press 'X' to ensure there's data in CKEditor before switching to source view.
.perform(function () {
return this.actions().sendKeys('x');
})
.pause(50)
// Go into source editing and verify that correct CSS class is added.
.click('.ck-source-editing-button')
.waitForElementVisible('.ck-source-editing-area')
.assert.valueContains(
'.ck-source-editing-area textarea',
'<pre><code class="language-twig">',
)
// Go back into WYSIWYG mode and hit enter three times to break out of code block.
.click('.ck-source-editing-button') // Disable source editing.
.waitForElementVisible('.ck-editor__editable:not(.ck-hidden)')
// Go to end of line.
.perform(function () {
return this.actions().sendKeys(browser.Keys.ARROW_RIGHT);
})
.pause(50)
// Hit Enter three times to break out of CKEditor's code block.
.perform(function () {
return this.actions().sendKeys(browser.Keys.ENTER);
})
.pause(50)
.perform(function () {
return this.actions().sendKeys(browser.Keys.ENTER);
})
.pause(50)
.perform(function () {
return this.actions().sendKeys(browser.Keys.ENTER);
})
.pause(50)
// Open up the code syntax dropdown, and click the 2nd item (which should be 'YML').
.click(
'.ck-code-block-dropdown .ck-dropdown__button .ck-splitbutton__arrow',
)
.click(
'.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item:nth-child(2) button',
)
// Press 'X' to ensure there's data in CKEditor before switching to source view.
.perform(function () {
return this.actions().sendKeys('x');
})
// Go into source editing and verify that correct CSS class is added.
.click('.ck-source-editing-button')
.waitForElementVisible('.ck-source-editing-area')
.assert.valueContains(
'.ck-source-editing-area textarea',
'<pre><code class="language-yml">',
);
});
},
};

View File

@@ -0,0 +1,240 @@
// cspell:ignore sourceediting
module.exports = {
'@tags': ['core', 'ckeditor5'],
before(browser) {
browser
.drupalInstall({ installProfile: 'minimal' })
.drupalInstallModule('ckeditor5', true)
.drupalInstallModule('field_ui');
// Set fixed (desktop-ish) size to ensure a maximum viewport.
browser.resizeWindow(1920, 1080);
},
after(browser) {
browser.drupalUninstall();
},
'Ensure CKEditor respects field widget row value': (browser) => {
browser.drupalLoginAsAdmin(() => {
browser
// Create new input format.
.drupalRelativeURL('/admin/config/content/formats/add')
.waitForElementVisible('[data-drupal-selector="edit-name"]')
.updateValue('[data-drupal-selector="edit-name"]', 'test')
.waitForElementVisible('#edit-name-machine-name-suffix')
.click(
'[data-drupal-selector="edit-editor-editor"] option[value=ckeditor5]',
)
// Wait for CKEditor 5 settings to be visible.
.waitForElementVisible(
'[data-drupal-selector="edit-editor-settings-toolbar"]',
)
.click('.ckeditor5-toolbar-button-sourceEditing') // Select the Source Editing button.
// Hit the down arrow key to move it to the toolbar.
.perform(function () {
return this.actions().sendKeys(browser.Keys.ARROW_DOWN);
})
// Wait for new source editing vertical tab to be present before continuing.
.waitForElementVisible(
'[href*=edit-editor-settings-plugins-ckeditor5-sourceediting]',
)
.submitForm('input[type="submit"]')
.waitForElementVisible('[data-drupal-messages]')
.assert.textContains('[data-drupal-messages]', 'Added text format')
// Create new content type.
.drupalRelativeURL('/admin/structure/types/add')
.waitForElementVisible('[data-drupal-selector="edit-name"]')
.updateValue('[data-drupal-selector="edit-name"]', 'test')
.waitForElementVisible('#edit-name-machine-name-suffix') // Wait for machine name to update.
.submitForm('input[type="submit"]')
.waitForElementVisible('[data-drupal-messages]')
.assert.textContains(
'[data-drupal-messages]',
'The content type test has been added',
)
// Navigate to the create content page and measure height of the editor.
.drupalRelativeURL('/node/add/test')
.waitForElementVisible('.ck-editor__editable')
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow
function () {
const height = document.querySelector(
'.ck-editor__editable',
).clientHeight;
// We expect height to be 320, but test to ensure that it's greater
// than 300. We want to ensure that we don't hard code a very specific
// value because tests might break if styles change (line-height, etc).
// Note that the default height for CKEditor5 is 47px.
return height > 300;
},
[],
(result) => {
browser.assert.ok(
result.value,
'Editor height is set to 9 rows (default).',
);
},
)
.click('.ck-source-editing-button')
.waitForElementVisible('.ck-source-editing-area')
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow
function () {
const height = document.querySelector(
'.ck-source-editing-area',
).clientHeight;
// We expect height to be 320, but test to ensure that it's greater
// than 300. We want to ensure that we don't hard code a very specific
// value because tests might break if styles change (line-height, etc).
// Note that the default height for CKEditor5 is 47px.
return height > 300;
},
[],
(result) => {
browser.assert.ok(
result.value,
'Source editing height is set to 9 rows (default).',
);
},
)
// Navigate to the create content page and measure max-height of the editor.
.drupalRelativeURL('/node/add/test')
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow
function () {
window.Drupal.CKEditor5Instances.forEach((instance) => {
instance.setData('<p>Llamas are cute.</p>'.repeat(100));
});
const height = document.querySelector(
'.ck-editor__editable',
).clientHeight;
return height < window.innerHeight;
},
[],
(result) => {
browser.assert.ok(
result.value,
'Editor area should never exceed full viewport.',
);
},
)
// Source Editor textarea should have vertical scrollbar when needed.
.click('.ck-source-editing-button')
.waitForElementVisible('.ck-source-editing-area')
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow
function () {
function isScrollableY(element) {
const style = window.getComputedStyle(element);
if (
element.scrollHeight > element.clientHeight &&
style.overflow !== 'hidden' &&
style['overflow-y'] !== 'hidden' &&
style.overflow !== 'clip' &&
style['overflow-y'] !== 'clip'
) {
if (
element === document.scrollingElement ||
(style.overflow !== 'visible' &&
style['overflow-y'] !== 'visible')
) {
return true;
}
}
return false;
}
return isScrollableY(
document.querySelector('.ck-source-editing-area textarea'),
);
},
[],
(result) => {
browser.assert.strictEqual(
result.value,
true,
'Source Editor textarea should have vertical scrollbar when needed.',
);
},
)
// Double the editor row count.
.drupalRelativeURL('/admin/structure/types/manage/test/form-display')
.waitForElementVisible(
'[data-drupal-selector="edit-fields-body-settings-edit"]',
)
.click('[data-drupal-selector="edit-fields-body-settings-edit"]')
.waitForElementVisible(
'[data-drupal-selector="edit-fields-body-settings-edit-form-settings-rows"]',
)
.updateValue(
'[data-drupal-selector="edit-fields-body-settings-edit-form-settings-rows"]',
'18',
)
// Save field settings.
.click(
'[data-drupal-selector="edit-fields-body-settings-edit-form-actions-save-settings"]',
)
.waitForElementVisible(
'[data-drupal-selector="edit-fields-body"] .field-plugin-summary',
)
.click('[data-drupal-selector="edit-submit"]')
.waitForElementVisible('[data-drupal-messages]')
.assert.textContains(
'[data-drupal-messages]',
'Your settings have been saved',
)
// Navigate to the create content page and measure height of the editor.
.drupalRelativeURL('/node/add/test')
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow
function () {
const height = document.querySelector(
'.ck-editor__editable',
).clientHeight;
// We expect height to be 640, but test to ensure that it's greater
// than 600. We want to ensure that we don't hard code a very specific
// value because tests might break if styles change (line-height, etc).
// Note that the default height for CKEditor5 is 47px.
return height > 600;
},
[],
(result) => {
browser.assert.ok(result.value, 'Editor height is set to 18 rows.');
},
)
.click('.ck-source-editing-button')
.waitForElementVisible('.ck-source-editing-area')
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow
function () {
const height = document.querySelector(
'.ck-source-editing-area',
).clientHeight;
// We expect height to be 640, but test to ensure that it's greater
// than 600. We want to ensure that we don't hard code a very specific
// value because tests might break if styles change (line-height, etc).
// Note that the default height for CKEditor5 is 47px.
return height > 600;
},
[],
(result) => {
browser.assert.ok(
result.value,
'Source editing height is set to 18 rows (default).',
);
},
);
});
},
};

View File

@@ -0,0 +1,162 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
// eslint-disable-next-line import/no-extraneous-dependencies
const { JSDOM } = require('jsdom');
// Nightwatch doesn't support ES modules. This workaround loads the class
// directly here.
// @todo remove this after https://www.drupal.org/project/drupal/issues/3247647
// has been resolved.
// eslint-disable-next-line no-eval
const DrupalHtmlBuilder = eval(
`(${fs
.readFileSync(
path.resolve(
__dirname,
'../../../../js/ckeditor5_plugins/drupalHtmlEngine/src/drupalhtmlbuilder.js',
),
)
.toString()})`.replace('export default', ''),
);
const { document, Node } = new JSDOM(`<!DOCTYPE html>`).window;
module.exports = {
'@tags': ['ckeditor5'],
'@unitTest': true,
'should return empty string when empty DocumentFragment is passed':
function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
drupalHtmlBuilder.appendNode(document.createDocumentFragment());
assert.equal(drupalHtmlBuilder.build(), '');
},
'should create text from single text node': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const text = 'foo bar';
const fragment = document.createDocumentFragment();
const textNode = document.createTextNode(text);
fragment.appendChild(textNode);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(drupalHtmlBuilder.build(), text);
},
'should return correct HTML from fragment with paragraph': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const paragraph = document.createElement('p');
paragraph.textContent = 'foo bar';
fragment.appendChild(paragraph);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(drupalHtmlBuilder.build(), '<p>foo bar</p>');
},
'should return correct HTML from fragment with multiple child nodes':
function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const text = document.createTextNode('foo bar');
const paragraph = document.createElement('p');
const div = document.createElement('div');
paragraph.textContent = 'foo';
div.textContent = 'bar';
fragment.appendChild(text);
fragment.appendChild(paragraph);
fragment.appendChild(div);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(
drupalHtmlBuilder.build(),
'foo bar<p>foo</p><div>bar</div>',
);
},
'should return correct HTML scripts and styles': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const script = document.createElement('script');
script.textContent = `let x = 10;
let y = 5;
if (y < x) {
console.log('is smaller')
}`;
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.appendChild(
document.createTextNode(':root .sections > h2 { background: red}'),
);
fragment.appendChild(style);
fragment.appendChild(document.createTextNode('\n'));
fragment.appendChild(script);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(
drupalHtmlBuilder.build(),
`<style type="text/css">:root .sections > h2 { background: red}</style>
<script>let x = 10;
let y = 5;
if (y < x) {
console.log('is smaller')
}</script>`,
);
},
'should return correct HTML from fragment with comment': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const div = document.createElement('div');
const comment = document.createComment('bar');
div.textContent = 'bar';
fragment.appendChild(div);
fragment.appendChild(comment);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(drupalHtmlBuilder.build(), '<div>bar</div><!--bar-->');
},
'should return correct HTML from fragment with attributes': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const div = document.createElement('div');
div.setAttribute('id', 'foo');
div.classList.add('bar');
div.textContent = 'baz';
fragment.appendChild(div);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(
drupalHtmlBuilder.build(),
'<div id="foo" class="bar">baz</div>',
);
},
'should return correct HTML from fragment with self closing tag':
function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const hr = document.createElement('hr');
fragment.appendChild(hr);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(drupalHtmlBuilder.build(), '<hr>');
},
'attribute values should be escaped': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const div = document.createElement('div');
div.setAttribute('data-caption', 'Kittens & llamas are <em>cute</em>');
div.textContent = 'foo';
fragment.appendChild(div);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(
drupalHtmlBuilder.build(),
'<div data-caption="Kittens &amp; llamas are &lt;em&gt;cute&lt;/em&gt;">foo</div>',
);
},
};

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Traits;
use Behat\Mink\Element\NodeElement;
use Drupal\Component\Utility\Html;
// cspell:ignore downcasted
/**
* Provides methods to test CKEditor 5.
*
* This trait is meant to be used only by functional JavaScript test classes.
*/
trait CKEditor5TestTrait {
/**
* Gets CKEditor 5 instance data as a PHP DOMDocument.
*
* @return \DOMDocument
* The result of parsing CKEditor 5's data into a PHP DOMDocument.
*/
protected function getEditorDataAsDom(): \DOMDocument {
return Html::load($this->getEditorDataAsHtmlString());
}
/**
* Gets CKEditor 5 instance data as a HTML string.
*
* @return string
* The result of retrieving CKEditor 5's data.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/api/module_editor-classic_classiceditor-ClassicEditor.html#function-getData
*/
protected function getEditorDataAsHtmlString(): string {
// We cannot trust on CKEditor updating the textarea every time model
// changes. Therefore, the most reliable way to get downcasted data is to
// use the CKEditor API.
$javascript = <<<JS
(function(){
return Drupal.CKEditor5Instances.get(Drupal.CKEditor5Instances.keys().next().value).getData();
})();
JS;
return $this->getSession()->evaluateScript($javascript);
}
/**
* Waits for CKEditor to initialize.
*/
protected function waitForEditor() {
$assert_session = $this->assertSession();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
}
/**
* Clicks a CKEditor button.
*
* @param string $name
* The name of the button, such as `drupallink`, `source`, etc.
*/
protected function pressEditorButton($name) {
$this->getEditorButton($name)->click();
}
/**
* Waits for a CKEditor button and returns it when available and visible.
*
* @param string $name
* The name of the button, such as `drupallink`, `source`, etc.
*
* @return \Behat\Mink\Element\NodeElement|null
* The page element node if found, NULL if not.
*/
protected function getEditorButton($name) {
$button = $this->assertSession()->waitForElementVisible('xpath', "//button[span[text()='$name']]");
$this->assertNotEmpty($button);
return $button;
}
/**
* Asserts a CKEditor button is disabled.
*
* @param string $name
* The name of the button, such as `drupallink`, `source`, etc.
*/
protected function assertEditorButtonDisabled($name) {
$button = $this->getEditorButton($name);
$this->assertTrue($button->hasAttribute('aria-disabled'));
$this->assertTrue($button->hasClass('ck-disabled'));
}
/**
* Asserts a CKEditor button is enabled.
*
* @param string $name
* The name of the button, such as `drupallink`, `source`, etc.
*/
protected function assertEditorButtonEnabled($name) {
$button = $this->getEditorButton($name);
$this->assertFalse($button->hasAttribute('aria-disabled'));
$this->assertFalse($button->hasClass('ck-disabled'));
}
/**
* Asserts a particular balloon is visible.
*
* @param string $balloon_content_selector
* A CSS selector.
*
* @return \Behat\Mink\Element\NodeElement
* The asserted balloon.
*/
protected function assertVisibleBalloon(string $balloon_content_selector): NodeElement {
$this->assertSession()->elementExists('css', '.ck-balloon-panel_visible');
$selector = ".ck-balloon-panel_visible .ck-balloon-rotator__content > .ck$balloon_content_selector";
$this->assertSession()->elementExists('css', $selector);
return $this->getSession()->getPage()->find('css', $selector);
}
/**
* Gets a button from the currently visible balloon.
*
* @param string $name
* The label of the button to find.
*
* @return \Behat\Mink\Element\NodeElement
* The requested button.
*/
protected function getBalloonButton(string $name): NodeElement {
$button = $this->getSession()->getPage()
->find('css', '.ck-balloon-panel_visible .ck-balloon-rotator__content')
->find('xpath', "//button[span[text()='$name']]");
$this->assertNotEmpty($button);
return $button;
}
/**
* Selects text inside an element.
*
* @param string $selector
* A CSS selector for the element which contents should be selected.
*/
protected function selectTextInsideElement(string $selector): void {
$javascript = <<<JS
(function() {
const el = document.querySelector(".ck-editor__main $selector");
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
})();
JS;
$this->getSession()->evaluateScript($javascript);
}
}

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