'
+ filter_html_help: true
+ filter_html_nofollow: false
diff --git a/core/modules/config_help/config/install/filter.format.help_plain.yml b/core/modules/config_help/config/install/filter.format.help_plain.yml
new file mode 100644
index 0000000..c0f6b5f
--- /dev/null
+++ b/core/modules/config_help/config/install/filter.format.help_plain.yml
@@ -0,0 +1,16 @@
+langcode: en
+status: true
+dependencies:
+ enforced:
+ module:
+ - config_help
+name: 'Help - plain text'
+format: help_plain
+weight: 0
+filters:
+ filter_html_escape:
+ id: filter_html_escape
+ provider: filter
+ status: true
+ weight: -10
+ settings: { }
diff --git a/core/modules/config_help/config/schema/config_help.schema.yml b/core/modules/config_help/config/schema/config_help.schema.yml
new file mode 100644
index 0000000..c8df7e7
--- /dev/null
+++ b/core/modules/config_help/config/schema/config_help.schema.yml
@@ -0,0 +1,41 @@
+config_help.topic.*:
+ type: config_entity
+ label: 'Help topic'
+ mapping:
+ id:
+ type: string
+ label: 'Machine-readable name'
+ label:
+ type: label
+ label: 'Title'
+ top_level:
+ type: boolean
+ label: 'Top-level topic'
+ locked:
+ type: boolean
+ label: 'Locked'
+ related:
+ type: sequence
+ label: 'Related topics'
+ sequence:
+ type: string
+ list_on:
+ type: sequence
+ label: 'List on topics'
+ sequence:
+ type: string
+ body:
+ type: sequence
+ label: 'Body'
+ sequence:
+ type: config_help_text
+
+config_help_text:
+ type: mapping
+ mapping:
+ id:
+ label: 'Type'
+ type: string
+ text:
+ label: 'Text'
+ type: text_format
diff --git a/core/modules/config_help/config_help.api.php b/core/modules/config_help/config_help.api.php
new file mode 100644
index 0000000..80056bb
--- /dev/null
+++ b/core/modules/config_help/config_help.api.php
@@ -0,0 +1,35 @@
+ 'heading',
+ '#text' => t('About'),
+ ];
+ $sections[] = [
+ '#section_type' => 'paragraph',
+ '#text' => t('The Configurable Help module adds configurable help topics to the module-provided help from the core Help module. For more information, see the online documentation for the Configurable Help module.', [':online' => 'https://www.drupal.org/modules/config_help']),
+ ];
+ $sections[] = [
+ '#section_type' => 'heading',
+ '#text' => ['#markup' => t('Uses')],
+ ];
+ $sections[] = [
+ '#section_type' => 'description_name',
+ '#text' => t('Configuring help topics'),
+ ];
+ $sections[] = [
+ '#section_type' => 'description_value',
+ '#text' => t('You can add, edit, delete, and translate configure help topics on the Help topics administration page. The help topics that are listed in the Module help section of the main Help page cannot be edited or deleted. See the Building a help system topic for more information on configuring help topics.', [
+ ':topic_admin' => Url::fromRoute('entity.help_topic.collection')->toString(),
+ ':main_topic' => Url::fromRoute('entity.help_topic.canonical', ['help_topic' => 'config_help'])->toString(),
+ ]),
+ ];
+ $sections[] = [
+ '#section_type' => 'description_name',
+ '#text' => t('Viewing configurable help topics'),
+ ];
+ $sections[] = [
+ '#section_type' => 'description_value',
+ '#text' => t('The top-level configured help topics are listed on the main Help page.', [':help_page' => Url::fromRoute('help.main')->toString()]),
+ ];
+ $sections[] = [
+ '#section_type' => 'description_name',
+ '#text' => t('Updating help topics'),
+ ];
+ $sections[] = [
+ '#section_type' => 'description_value',
+ '#text' => t('Help topics provided by modules and themes may be updated when a module or theme is updated, or new topics may be added. However, help topics are only imported into your site configuration when you first install the module or theme. The contributed Configuration Update Manager module can be used to check for updates, see differences, and import or update topics that have been added or have changed. It is advisable not to edit module- or theme-provided topics, to make updates easier.', [':config_update' => 'https://www.drupal.org/project/config_update']),
+ ];
+
+ return [
+ '#type' => 'text_sections',
+ '#sections' => $sections,
+ ];
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function config_help_theme($existing, $type, $theme, $path) {
+ return [
+ 'help_topic' => [
+ 'variables' => [
+ 'body' => [],
+ 'related' => [],
+ ],
+ ],
+ ];
+}
diff --git a/core/modules/config_help/config_help.permissions.yml b/core/modules/config_help/config_help.permissions.yml
new file mode 100644
index 0000000..c072b6e
--- /dev/null
+++ b/core/modules/config_help/config_help.permissions.yml
@@ -0,0 +1,7 @@
+administer help topics:
+ title: 'Administer configured help topics'
+ description: 'Create, edit, and delete unlocked help topics'
+view help topics:
+ title: 'View configured help topics'
+administer help topic locking:
+ title: 'Lock and unlock configured help topics'
diff --git a/core/modules/config_help/config_help.routing.yml b/core/modules/config_help/config_help.routing.yml
new file mode 100644
index 0000000..122da2b
--- /dev/null
+++ b/core/modules/config_help/config_help.routing.yml
@@ -0,0 +1,20 @@
+config_help.topic_autocomplete:
+ path: '/config-help/autocomplete-topic'
+ defaults:
+ _controller: '\Drupal\config_help\Controller\AutocompleteController::topicAutocomplete'
+ requirements:
+ _permission: 'administer help topics'
+
+config_help.module_autocomplete:
+ path: '/config-help/autocomplete-module'
+ defaults:
+ _controller: '\Drupal\config_help\Controller\AutocompleteController::moduleAutocomplete'
+ requirements:
+ _permission: 'administer help topics'
+
+config_help.theme_autocomplete:
+ path: '/config-help/autocomplete-theme'
+ defaults:
+ _controller: '\Drupal\config_help\Controller\AutocompleteController::themeAutocomplete'
+ requirements:
+ _permission: 'administer help topics'
diff --git a/core/modules/config_help/config_help.services.yml b/core/modules/config_help/config_help.services.yml
new file mode 100644
index 0000000..30ecb77
--- /dev/null
+++ b/core/modules/config_help/config_help.services.yml
@@ -0,0 +1,10 @@
+services:
+ config_help.breadcrumb:
+ class: Drupal\config_help\HelpBreadcrumbBuilder
+ arguments: ['@string_translation']
+ tags:
+ - { name: breadcrumb_builder, priority: 900 }
+
+ plugin.manager.text_section:
+ class: Drupal\config_help\TextSectionManager
+ parent: default_plugin_manager
diff --git a/core/modules/config_help/config_help.tokens.inc b/core/modules/config_help/config_help.tokens.inc
new file mode 100644
index 0000000..01c8277
--- /dev/null
+++ b/core/modules/config_help/config_help.tokens.inc
@@ -0,0 +1,81 @@
+ t("Route information"),
+ 'description' => t("Tokens based on route names."),
+ ];
+
+ $routes = [];
+ $routes['url'] = [
+ 'name' => t('URL'),
+ 'description' => t('The URL to the route. Provide the route name as route:url:ROUTE_NAME'),
+ ];
+
+ $types['help_topic'] = [
+ 'name' => t("Help Topics"),
+ 'description' => t("Tokens for help topics."),
+ ];
+
+ $topics = [];
+ $topics['url'] = [
+ 'name' => t('URL'),
+ 'description' => t('The URL to the topic. Provide the topic machine name as help_topic:url:MACHINE_NAME'),
+ ];
+
+ return [
+ 'types' => $types,
+ 'tokens' => [
+ 'help_topic' => $topics,
+ 'route' => $routes,
+ ],
+ ];
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function config_help_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
+ $replacements = [];
+ $token_service = \Drupal::token();
+
+ if ($type == 'help_topic') {
+ $topics = $token_service->findWithPrefix($tokens, 'url');
+ foreach ($topics as $name => $original) {
+ if ($topic = HelpTopic::load($name)) {
+ $replacements[$original] = $topic->url('canonical');
+ $bubbleable_metadata->addCacheableDependency($topic);
+ }
+ }
+ }
+ elseif ($type == 'route') {
+ $routes = $token_service->findWithPrefix($tokens, 'url');
+ foreach ($routes as $route_name => $original) {
+ try {
+ $url = Url::fromRoute($route_name)->toString();
+ }
+ catch (\Exception $e) {
+ // Invalid route or missing parameters or something like that.
+ // Do nothing.
+ $url = FALSE;
+ }
+ if ($url) {
+ $replacements[$original] = $url;
+ }
+ }
+ }
+
+ return $replacements;
+}
diff --git a/core/modules/config_help/src/Annotation/TextSection.php b/core/modules/config_help/src/Annotation/TextSection.php
new file mode 100644
index 0000000..95714a5
--- /dev/null
+++ b/core/modules/config_help/src/Annotation/TextSection.php
@@ -0,0 +1,57 @@
+storage = $entity_type_manager->getStorage('help_topic');
+ $this->moduleHandler = $module_handler;
+ $this->themeHandler = $theme_handler;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('module_handler'),
+ $container->get('theme_handler')
+ );
+ }
+
+ /**
+ * Retrieves suggestions for help topic autocomplete.
+ *
+ * The autocomplete suggestions search for matches by topic title and machine
+ * name, and are returned in a JSON response for use in an edit form field.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request object.
+ *
+ * @return \Symfony\Component\HttpFoundation\JsonResponse
+ * A JSON response containing the autocomplete suggestions.
+ */
+ public function topicAutocomplete(Request $request) {
+ $matches = [];
+ if ($input = $request->query->get('q')) {
+ $input = Tags::explode($input);
+ $input = Unicode::strtolower(array_pop($input));
+ $query = $this->storage->getQuery();
+ // Find matching topics by machine name or title.
+ $group = $query->orConditionGroup()
+ ->condition('id', $input, 'CONTAINS')
+ ->condition('label', $input, 'CONTAINS');
+ $ids = $query
+ ->condition($group)
+ ->range(0, 10)
+ ->sort('label')
+ ->execute();
+
+ if (!empty($ids)) {
+ $topics = $this->storage->loadMultiple($ids);
+ foreach ($topics as $topic) {
+ $matches[] = $this->getMatch($topic->id(), $topic->label());
+ }
+ }
+ }
+
+ return new JsonResponse($matches);
+ }
+
+ /**
+ * Retrieves suggestions for module autocomplete.
+ *
+ * The autocomplete suggestions search for matches by module displayed and
+ * machine name, and they are returned in a JSON response for use in an edit
+ * form field.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request object.
+ *
+ * @return \Symfony\Component\HttpFoundation\JsonResponse
+ * A JSON response containing the autocomplete suggestions.
+ */
+ public function moduleAutocomplete(Request $request) {
+ $matches = [];
+ if ($input = $request->query->get('q')) {
+ $input = Tags::explode($input);
+ $input = Unicode::strtolower(array_pop($input));
+
+ // Return only first 10 matches.
+ $limit = 10;
+ foreach ($this->getActiveModules() as $name => $label) {
+ // Add as a match if the typed text matches machine name or displayed
+ // name.
+ if (stripos($name, $input) !== FALSE || stripos($label, $input) !== FALSE) {
+ $matches[] = $this->getMatch($name, $label);
+ if (!--$limit) {
+ break;
+ }
+ }
+ }
+ }
+
+ return new JsonResponse($matches);
+ }
+
+ /**
+ * Retrieves suggestions for theme autocomplete.
+ *
+ * The autocomplete suggestions search for matches by theme displayed and
+ * machine name, and they are returned in a JSON response for use in an edit
+ * form field.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request object.
+ *
+ * @return \Symfony\Component\HttpFoundation\JsonResponse
+ * A JSON response containing the autocomplete suggestions.
+ */
+ public function themeAutocomplete(Request $request) {
+ $matches = [];
+ if ($input = $request->query->get('q')) {
+ $input = Tags::explode($input);
+ $input = Unicode::strtolower(array_pop($input));
+ // There is not an obvious way to query installed themes. So make
+ // a list of all themes, and filter it down to ones that match.
+ $installed = $this->themeHandler->listInfo();
+
+ // Return only first 10 matches.
+ $limit = 10;
+ foreach ($installed as $name => $extension) {
+ $label = $extension->info['name'];
+ // Add as a match if the typed text matches machine name or displayed
+ // name.
+ if (strpos($name, $input) !== FALSE || strpos($label, $input) !== FALSE) {
+ $matches[] = $this->getMatch($name, $label);
+ if (!--$limit) {
+ break;
+ }
+ }
+ }
+ }
+
+ return new JsonResponse($matches);
+ }
+
+ /**
+ * Builds structure to display in autocomplete dropdown.
+ *
+ * @param string $value
+ * Machine name part.
+ * @param string $label
+ * Human readable part.
+ *
+ * @return array
+ * An array of matched labels, in the format required by the Ajax
+ * autocomplete API (array('value' => $value, 'label' => $label)).
+ */
+ protected function getMatch($value, $label) {
+ return [
+ 'value' => $value,
+ 'label' => new HtmlEscapedText("$label ($value)"),
+ ];
+ }
+
+ /**
+ * Returns list of active modules' names.
+ *
+ * @return array
+ * An associative array of active modules' human names keyed by module name.
+ */
+ protected function getActiveModules() {
+ $installed = array_keys($this->moduleHandler->getModuleList());
+ $info = system_get_info('module');
+ $modules = [];
+ foreach ($installed as $extension) {
+ $modules[$extension] = $info[$extension]['name'];
+ }
+ return $modules;
+ }
+
+}
diff --git a/core/modules/config_help/src/Element/TextSections.php b/core/modules/config_help/src/Element/TextSections.php
new file mode 100644
index 0000000..0cca879
--- /dev/null
+++ b/core/modules/config_help/src/Element/TextSections.php
@@ -0,0 +1,95 @@
+ 'text_sections',
+ * '#sections' => [
+ * [
+ * '#section_type' => 'heading',
+ * '#text' => ['#markup' => t('Greetings')],
+ * ],
+ * [
+ * '#section_type' => 'paragraph',
+ * '#text' => ['#markup' => t('Hello, world.')],
+ * ],
+ * ],
+ * ];
+ * @endcode
+ *
+ * @see \Drupal\config_help\TextSectionPluginInterface
+ *
+ * @RenderElement("text_sections")
+ */
+class TextSections extends RenderElement {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getInfo() {
+ $class = get_class($this);
+ return [
+ '#pre_render' => [[$class, 'preRenderSections']],
+ ];
+ }
+
+ /**
+ * Expands a #text_sections element into an array of sections.
+ *
+ * @param array $element
+ * The form element to process. See main class documentation for properties.
+ *
+ * @return array
+ * The form element.
+ */
+ public static function preRenderSections(array $element) {
+ /** @var \Drupal\config_help\TextSectionPluginCollection */
+ $collection = new TextSectionPluginCollection(self::sectionManager(), []);
+ $collection->addFromRenderArray($element);
+
+ unset($element['#sections']);
+ unset($element['#type']);
+ return $collection->preRenderCollection(self::renderer(), $element);
+ }
+
+ /**
+ * Wraps the text section manager service.
+ *
+ * @return \Drupal\config_help\TextSectionManager
+ * The text section plugin manager.
+ */
+ protected static function sectionManager() {
+ return \Drupal::service('plugin.manager.text_section');
+ }
+
+ /**
+ * Wraps the renderer service.
+ *
+ * @return \Drupal\Core\Render\Renderer
+ * The renderer.
+ */
+ protected static function renderer() {
+ return \Drupal::service('renderer');
+ }
+
+}
diff --git a/core/modules/config_help/src/Entity/HelpTopic.php b/core/modules/config_help/src/Entity/HelpTopic.php
new file mode 100644
index 0000000..2f7b3d6
--- /dev/null
+++ b/core/modules/config_help/src/Entity/HelpTopic.php
@@ -0,0 +1,316 @@
+get('body');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setBody(array $body) {
+ // Setting this invalidates the plugin collection if already instantiated.
+ $this->pluginCollection = NULL;
+ return $this->set('body', $body);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isTopLevel() {
+ return $this->get('top_level');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setTopLevel($top_level) {
+ return $this->set('top_level', $top_level);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isLocked() {
+ return $this->get('locked');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setLocked($locked = TRUE) {
+ return $this->set('locked', $locked);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRelated() {
+ return $this->get('related');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setRelated(array $topics) {
+ return $this->set('related', $topics);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getListOn() {
+ return $this->get('list_on');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setListOn(array $topics) {
+ return $this->set('list_on', $topics);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getEnforcedDependencies() {
+ return [
+ 'module' => isset($this->dependencies['enforced']['module']) ? $this->dependencies['enforced']['module'] : [],
+ 'theme' => isset($this->dependencies['enforced']['theme']) ? $this->dependencies['enforced']['theme'] : [],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setEnforcedDependencies(array $dependencies) {
+ foreach (['module', 'theme'] as $key) {
+ if (empty($dependencies[$key])) {
+ unset($this->dependencies['enforced'][$key]);
+ }
+ else {
+ $this->dependencies['enforced'][$key] = $dependencies[$key];
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Makes a cache tag from a help topic ID.
+ *
+ * @param string $id
+ * The ID to make a cache tag from.
+ *
+ * @return string
+ * The cache tag that
+ * \Drupal\Core\Config\Entity\ConfigEntityBase::getCacheTagsToInvalidate()
+ * would return for the given ID, which matches the config object's name.
+ */
+ protected function makeCacheTag($id) {
+ return 'config:' . $this->getEntityType()->getConfigPrefix() . '.' . $id;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheTagsToInvalidate() {
+ // Get the standard bare cache tags.
+ $tags = parent::getCacheTagsToInvalidate();
+
+ // In addition to the standard entity tags, add the tags for entities
+ // this topic is listed on, so that when we edit or delete this entity,
+ // those others will also have their render caches invalidated.
+ foreach ($this->list_on as $topic) {
+ $tags[] = $this->makeCacheTag($topic);
+ }
+
+ return $tags;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheTags() {
+ // Get the standard bare entity cache tags.
+ $tags = parent::getCacheTagsToInvalidate();
+
+ // In addition to the standard entity tags, add the tags for entities
+ // listed on this topic, so that if they are edited or deleted, this one
+ // will have its render cache invalidated.
+ foreach ($this->related as $topic) {
+ $tags[] = $this->makeCacheTag($topic);
+ }
+
+ return $tags;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preSave(EntityStorageInterface $storage) {
+ parent::preSave($storage);
+
+ // Invalidate cache tags for topics this one was previously listed on.
+ if (!$this->isNew()) {
+ $original = $storage->loadUnchanged($this->getOriginalId());
+ $tags = [];
+ foreach ($original->list_on as $topic) {
+ $tags[] = $this->makeCacheTag($topic);
+ }
+ if (count($tags)) {
+ Cache::invalidateTags($tags);
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPluginCollections() {
+ if (!$this->pluginCollection) {
+ $this->pluginCollection = new TextSectionPluginCollection(\Drupal::service('plugin.manager.text_section'), $this->body);
+ }
+ return ['body' => $this->pluginCollection];
+ }
+
+}
diff --git a/core/modules/config_help/src/Form/HelpLockForm.php b/core/modules/config_help/src/Form/HelpLockForm.php
new file mode 100644
index 0000000..efa0094
--- /dev/null
+++ b/core/modules/config_help/src/Form/HelpLockForm.php
@@ -0,0 +1,53 @@
+t('Are you sure you want to lock the topic %name?', ['%name' => $this->entity->label()]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfirmText() {
+ return t('Lock');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCancelUrl() {
+ return new Url('entity.help_topic.collection');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $this->entity->setLocked();
+ $this->entity->save();
+ drupal_set_message(t('Locked help topic %name.', ['%name' => $this->entity->label()]));
+
+ $form_state->setRedirectUrl($this->getCancelUrl());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ return '' . $this->t('Locked topics cannot be edited or deleted until they are unlocked.') . '';
+ }
+
+}
diff --git a/core/modules/config_help/src/Form/HelpTopicForm.php b/core/modules/config_help/src/Form/HelpTopicForm.php
new file mode 100644
index 0000000..17a1592
--- /dev/null
+++ b/core/modules/config_help/src/Form/HelpTopicForm.php
@@ -0,0 +1,424 @@
+helpStorage = $entity_type_manager->getStorage('help_topic');
+ $this->moduleHandler = $module_handler;
+ $this->themeHandler = $theme_handler;
+ $this->sectionManager = $section_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('module_handler'),
+ $container->get('theme_handler'),
+ $container->get('plugin.manager.text_section')
+ );
+ }
+
+ /**
+ * Checks for an existing help topic.
+ *
+ * @param string $id
+ * The entity ID.
+ *
+ * @return bool
+ * TRUE if this topic already exists, FALSE otherwise.
+ */
+ public function exists($id) {
+ // Use load() method to leverage entity cache.
+ return (bool) $this->helpStorage->load($id);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ /** @var \Drupal\config_help\HelpTopicInterface $entity */
+ $entity = $this->entity;
+ $form['label'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Title'),
+ '#maxlength' => 100,
+ '#default_value' => $entity->label(),
+ '#required' => TRUE,
+ ];
+
+ $form['id'] = [
+ '#type' => 'machine_name',
+ '#default_value' => $entity->id(),
+ '#machine_name' => [
+ 'exists' => [$this, 'exists'],
+ 'error' => $this->t('The machine-readable name must be unique, and can only contain lowercase letters, numbers, and underscores.'),
+ ],
+ ];
+
+ $form['langcode'] = [
+ '#type' => 'language_select',
+ '#default_value' => $entity->language()->getId(),
+ '#title' => $this->t('Language'),
+ '#languages' => LanguageInterface::STATE_ALL,
+ ];
+
+ // The body is a sequence of elements. Use a standard multiple-value form
+ // element in the form, with an add new button.
+ // See how many elements we should make.
+ $body = $entity->getBody();
+ $bodycount = count($body);
+ $max = $bodycount ? $bodycount : 1;
+ $stuff = $form_state->getStorage();
+ if (isset($stuff['config_help_body_count'])) {
+ // This is coming from an Ajax request. Add one to the count
+ // so we build an additional form element.
+ $max = $stuff['config_help_body_count'] + 1;
+ }
+
+ // Save the information in storage.
+ $stuff['config_help_body_count'] = $max;
+ $form_state->setStorage($stuff);
+
+ // Build the wrapper element. This is borrowed from the Field API's
+ // \Drupal\Core\Field\WidgetBase::formMultipleElements() method.
+ $id_prefix = 'config-help-body-field';
+ $wrapper_id = Html::getUniqueId($id_prefix . '-add-more-wrapper');
+ $field_name = 'body';
+ $form[$field_name] = [
+ '#theme' => 'field_multiple_value_form',
+ '#tree' => TRUE,
+ '#field_name' => $field_name,
+ '#cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
+ '#cardinality_multiple' => TRUE,
+ '#required' => FALSE,
+ '#title' => $this->t('Body components'),
+ '#description' => $this->t('Compose the body of a help topic using headers, paragraphs, code areas, and list items.'),
+ '#max_delta' => $max,
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ // Add edit elements for each body component.
+ for ($delta = 0; $delta < $max; $delta++) {
+ if (!isset($body[$delta])) {
+ $body[$delta] = [
+ 'id' => '',
+ 'text' => [
+ 'value' => '',
+ 'format' => 'help',
+ ],
+ ];
+ }
+
+ $element = [
+ '#delta' => $delta,
+ '#weight' => $delta,
+ '#required' => FALSE,
+ ];
+ $element['id'] = [
+ '#type' => 'select',
+ '#options' => $this->sectionManager->getOptionsList(),
+ '#default_value' => $body[$delta]['id'],
+ ];
+ $element['text'] = [
+ '#type' => 'text_format',
+ '#default_value' => $body[$delta]['text']['value'],
+ '#format' => $body[$delta]['text']['format'],
+ '#description' => $this->t('You can use tokens like [route:url:ROUTE_NAME] and [help_topic:url:MACHINE_NAME] to insert URLs to administrative page routes and to other help topics into the text.'),
+ ];
+ $element['_weight'] = [
+ '#type' => 'weight',
+ '#title' => $this->t('Weight for row @number', ['@number' => $delta + 1]),
+ '#title_display' => 'invisible',
+ '#delta' => $max,
+ '#default_value' => $delta,
+ '#weight' => 100,
+ ];
+
+ $form[$field_name][$delta] = $element;
+ }
+
+ // Include the add more button.
+ $form[$field_name]['add_more'] = [
+ '#type' => 'submit',
+ '#name' => strtr($id_prefix, '-', '_') . '_add_more',
+ '#value' => $this->t('Add another item'),
+ '#attributes' => ['class' => ['field-add-more-submit']],
+ '#submit' => ['::addMoreSubmit'],
+ '#ajax' => [
+ 'callback' => '::addMoreAjax',
+ 'wrapper' => $wrapper_id,
+ 'effect' => 'fade',
+ ],
+ ];
+
+ $form['relationships'] = [
+ '#type' => 'details',
+ '#open' => TRUE,
+ '#title' => $this->t('Relationships and hierarchy'),
+ ];
+
+ $form['relationships']['top_level'] = [
+ '#type' => 'checkbox',
+ '#default_value' => $entity->isTopLevel(),
+ '#title' => $this->t('Top-level topic'),
+ '#description' => $this->t('Check box if this topic should be displayed on the Help page topics list'),
+ ];
+
+
+ $form['relationships']['related'] = [
+ '#title' => $this->t('Topics to list here'),
+ '#description' => $this->t("Topics to list in this topic's Related topics section. Comma-separated list of machine names."),
+ '#type' => 'textfield',
+ '#maxlength' => 5000,
+ '#default_value' => Tags::implode($entity->getRelated()),
+ '#autocomplete_route_name' => 'config_help.topic_autocomplete',
+ ];
+
+ $form['relationships']['list_on'] = [
+ '#title' => $this->t('List this topic on'),
+ '#description' => $this->t("Topics that should have this topic listed in their Related topics sections. Comma-separated list of machine names."),
+ '#type' => 'textfield',
+ '#maxlength' => 5000,
+ '#default_value' => Tags::implode($entity->getListOn()),
+ '#autocomplete_route_name' => 'config_help.topic_autocomplete',
+ ];
+
+ $dependencies = $entity->getEnforcedDependencies();
+
+ $form['dependencies'] = [
+ '#type' => 'details',
+ '#open' => FALSE,
+ '#title' => $this->t('Dependencies'),
+ '#description' => $this->t('Primarily for use by module and theme developers, for topics they plan to export to distribute with their modules or themes.'),
+ ];
+
+ $form['dependencies']['modules'] = [
+ '#title' => $this->t('Module dependencies'),
+ '#description' => $this->t('Comma-separated list of machine names of modules this help topic depends on.'),
+ '#type' => 'textfield',
+ '#maxlength' => 5000,
+ '#default_value' => Tags::implode($dependencies['module']),
+ '#autocomplete_route_name' => 'config_help.module_autocomplete',
+ ];
+
+ $form['dependencies']['themes'] = [
+ '#title' => $this->t('Theme dependencies'),
+ '#description' => $this->t('Comma-separated list of machine names of themes this help topic depends on.'),
+ '#type' => 'textfield',
+ '#maxlength' => 5000,
+ '#default_value' => Tags::implode($dependencies['theme']),
+ '#autocomplete_route_name' => 'config_help.theme_autocomplete',
+ ];
+
+ $form['#entity_builders'][] = '::copyTopicFieldsToEntity';
+
+ return parent::form($form, $form_state);
+ }
+
+ /**
+ * Handles the Ajax submit for the add more button.
+ *
+ * Modified from \Drupal\Core\Field\WidgetBase::addMoreSubmit().
+ */
+ public static function addMoreSubmit(array $form, FormStateInterface $form_state) {
+ $form_state->setRebuild();
+ }
+
+ /**
+ * Handles the Ajax response for the add more button.
+ *
+ * Modified from \Drupal\Core\Field\WidgetBase::addMoreAjax().
+ */
+ public static function addMoreAjax(array $form, FormStateInterface $form_state) {
+ // Go one level up from the Add More button, so we're looking at the list.
+ $button = $form_state->getTriggeringElement();
+ $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
+
+ // Add a DIV around the delta receiving the Ajax effect.
+ $latest = $element['#max_delta'];
+ if (isset($element[$latest])) {
+ $element[$latest]['#prefix'] = '' . (isset($element[$latest]['#prefix']) ? $element[$latest]['#prefix'] : '');
+ $element[$latest]['#suffix'] = (isset($element[$latest]['#suffix']) ? $element[$latest]['#suffix'] : '') . '
';
+ }
+
+ return $element;
+ }
+
+ /**
+ * Copies the topics and dependencies field values to the entity properties.
+ *
+ * This is added to $form['#entity_builders'] in the form builder method.
+ *
+ * @param string $type
+ * Type of entity.
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * Entity to copy property values to.
+ * @param array $form
+ * Form array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Form state.
+ */
+ protected function copyTopicFieldsToEntity($type, EntityInterface $entity, array &$form, FormStateInterface &$form_state) {
+ $entity
+ ->setRelated(Tags::explode($form_state->getValue('related')))
+ ->setListOn(Tags::explode($form_state->getValue('list_on')))
+ ->setEnforcedDependencies([
+ 'module' => Tags::explode($form_state->getValue('modules')),
+ 'theme' => Tags::explode($form_state->getValue('themes')),
+ ])
+ ->setBody($this->cleanBody($form_state->getValue('body')));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ // Make sure that the two reference fields only contain machine names of
+ // actual help topics.
+ foreach (['related', 'list_on'] as $field) {
+ $list = Tags::explode($form_state->getValue($field));
+ $existing = array_keys($this->helpStorage->loadMultiple($list));
+ $missing = array_diff($list, $existing);
+ if ($missing) {
+ $form_state->setErrorByName($field, $this->t('Must be a comma-separated list of existing topic machine names (%problem)', [
+ '%problem' => Tags::implode($missing),
+ ]));
+ }
+ }
+
+ $list = Tags::explode($form_state->getValue('modules'));
+ $missing = [];
+ foreach ($list as $module) {
+ if (!$this->moduleHandler->moduleExists($module)) {
+ $missing[] = $module;
+ }
+ }
+ if ($missing) {
+ $form_state->setErrorByName('modules', $this->t('Must be a comma-separated list of installed module machine names (%problem)', ['%problem' => Tags::implode($missing)]));
+ }
+
+ $list = Tags::explode($form_state->getValue('themes'));
+ $missing = [];
+ foreach ($list as $theme) {
+ if (!$this->themeHandler->themeExists($theme)) {
+ $missing[] = $theme;
+ }
+ }
+ if ($missing) {
+ $form_state->setErrorByName('themes', $this->t('Must be a comma-separated list of installed theme machine names (%problem)', ['%problem' => Tags::implode($missing)]));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function save(array $form, FormStateInterface $form_state) {
+ $form_state->setRedirect('entity.help_topic.collection');
+
+ $status = $this->entity->save();
+ if ($status == SAVED_UPDATED) {
+ drupal_set_message($this->t('Help topic updated.'));
+ }
+ else {
+ drupal_set_message($this->t('Help topic added.'));
+ }
+ }
+
+ /**
+ * Cleans an unprocessed body field value.
+ *
+ * @param array $body
+ * Array of body text pieces.
+ *
+ * @return array
+ * List of non-empty body text pieces, in order by weight.
+ */
+ protected function cleanBody(array $body) {
+ if (!$body || !is_array($body)) {
+ return [];
+ }
+
+ $cleaned = [];
+ foreach ($body as $piece) {
+ if (is_array($piece) && isset($piece['id']) && isset($piece['text']['value']) && isset($piece['text']['format'])) {
+ // Make sure there is actually something relevant in the text.
+ $text = trim($piece['text']['value']);
+ if ($text) {
+ $piece['text']['value'] = $text;
+ $weight = $piece['_weight'];
+ unset($piece['_weight']);
+ $cleaned[$weight] = $piece;
+ }
+ }
+ }
+
+ ksort($cleaned);
+ return array_values($cleaned);
+ }
+
+}
diff --git a/core/modules/config_help/src/Form/HelpUnlockForm.php b/core/modules/config_help/src/Form/HelpUnlockForm.php
new file mode 100644
index 0000000..c12e793
--- /dev/null
+++ b/core/modules/config_help/src/Form/HelpUnlockForm.php
@@ -0,0 +1,53 @@
+t('Are you sure you want to unlock the topic %name?', ['%name' => $this->entity->label()]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfirmText() {
+ return t('Unlock');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCancelUrl() {
+ return new Url('entity.help_topic.collection');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $this->entity->setLocked(FALSE);
+ $this->entity->save();
+ drupal_set_message(t('Unlocked help topic %name.', ['%name' => $this->entity->label()]));
+
+ $form_state->setRedirectUrl($this->getCancelUrl());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ return '' . $this->t('Locked topics are typically provided by modules, themes, or installation profiles. They cannot be edited or deleted until they are unlocked. Unlocking and editing or deleting topics provided by a module, theme, or installation profile is not recommended.') . '';
+ }
+
+}
diff --git a/core/modules/config_help/src/HelpAccessControlHandler.php b/core/modules/config_help/src/HelpAccessControlHandler.php
new file mode 100644
index 0000000..0774ab0
--- /dev/null
+++ b/core/modules/config_help/src/HelpAccessControlHandler.php
@@ -0,0 +1,52 @@
+isLocked() && $operation == 'unlock') {
+ return AccessResult::forbidden()->addCacheableDependency($entity);
+ }
+
+ // Cannot lock if it is already locked.
+ if ($entity->isLocked() && $operation == 'lock') {
+ return AccessResult::forbidden()->addCacheableDependency($entity);
+ }
+
+ // Deny all operations except unlock if it is currently locked. They need
+ // to unlock first.
+ if ($entity->isLocked() && $operation != 'unlock') {
+ return AccessResult::forbidden()->addCacheableDependency($entity);
+ }
+
+ // For lock/unlock, use the locking permission. Note that we've already
+ // checked something on the entity, so make sure to add cache dependency.
+ if ($operation == 'unlock' || $operation == 'lock') {
+ return AccessResult::allowedIfHasPermission($account, 'administer help topic locking')->addCacheableDependency($entity);
+ }
+
+ // For all remaining operations, use the generic administer permission.
+ // Note that we've already checked something on the entity, so make sure to
+ // add cache dependency.
+ return AccessResult::allowedIfHasPermission($account, 'administer help topics')->addCacheableDependency($entity);
+ }
+
+}
diff --git a/core/modules/config_help/src/HelpBreadcrumbBuilder.php b/core/modules/config_help/src/HelpBreadcrumbBuilder.php
new file mode 100644
index 0000000..df0ec47
--- /dev/null
+++ b/core/modules/config_help/src/HelpBreadcrumbBuilder.php
@@ -0,0 +1,49 @@
+stringTranslation = $string_translation;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function applies(RouteMatchInterface $route_match) {
+ return $route_match->getRouteName() == 'entity.help_topic.canonical';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function build(RouteMatchInterface $route_match) {
+ $breadcrumb = new Breadcrumb();
+ $breadcrumb->addLink(Link::createFromRoute($this->t('Home'), ''));
+ $breadcrumb->addLink(Link::createFromRoute($this->t('Administration'), 'system.admin'));
+ $breadcrumb->addLink(Link::createFromRoute($this->t('Help'), 'help.main'));
+ $breadcrumb->addCacheContexts(['route.name']);
+
+ return $breadcrumb;
+ }
+
+}
diff --git a/core/modules/config_help/src/HelpListBuilder.php b/core/modules/config_help/src/HelpListBuilder.php
new file mode 100644
index 0000000..cb68c4c
--- /dev/null
+++ b/core/modules/config_help/src/HelpListBuilder.php
@@ -0,0 +1,102 @@
+ $this->t('Title'),
+ 'id' => $this->t('Machine name'),
+ 'top_level' => [
+ 'data' => $this->t('Top level'),
+ 'class' => [RESPONSIVE_PRIORITY_MEDIUM],
+ ],
+ 'locked' => [
+ 'data' => $this->t('Locked'),
+ 'class' => [RESPONSIVE_PRIORITY_MEDIUM],
+ ],
+ ];
+
+ return $header + parent::buildHeader();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildRow(EntityInterface $entity) {
+ $row = [];
+
+ $row['label']['data'] = [
+ '#type' => 'link',
+ '#title' => $this->getLabel($entity),
+ '#url' => $entity->urlInfo('canonical'),
+ ];
+
+ $row['id'] = $entity->id();
+
+ $row['top_level'] = ($entity->isTopLevel()) ? $this->t('Yes') : $this->t('No');
+ $row['locked'] = ($entity->isLocked()) ? $this->t('Yes') : $this->t('No');
+
+ return $row + parent::buildRow($entity);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEntityIds() {
+ // Override the default method to sort by label, since the paged table
+ // is eventually sorted by label within each page.
+ $query = $this->getStorage()->getQuery()
+ ->sort($this->entityType->getKey('label'));
+
+ if ($this->limit) {
+ $query->pager($this->limit);
+ }
+
+ return $query->execute();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDefaultOperations(EntityInterface $entity) {
+ $operations = parent::getDefaultOperations($entity);
+
+ // The default operations from the parent are edit and delete. Add
+ // view, lock, and unlock (if allowed).
+ if ($entity->access('view')) {
+ $operations['view'] = [
+ 'title' => $this->t('View'),
+ 'weight' => 20,
+ 'url' => $entity->urlInfo('canonical'),
+ ];
+ }
+ if ($entity->access('lock')) {
+ $operations['lock'] = [
+ 'title' => $this->t('Lock'),
+ 'weight' => 30,
+ 'url' => $entity->urlInfo('lock-form'),
+ ];
+ }
+ if ($entity->access('unlock')) {
+ $operations['unlock'] = [
+ 'title' => $this->t('Unlock'),
+ 'weight' => 30,
+ 'url' => $entity->urlInfo('unlock-form'),
+ ];
+ }
+
+ return $operations;
+ }
+
+}
diff --git a/core/modules/config_help/src/HelpTopicInterface.php b/core/modules/config_help/src/HelpTopicInterface.php
new file mode 100644
index 0000000..e742357
--- /dev/null
+++ b/core/modules/config_help/src/HelpTopicInterface.php
@@ -0,0 +1,136 @@
+setDefaults([
+ '_entity_form' => 'help_topic.unlock',
+ '_title' => 'Unlock help topic',
+ ])
+ ->setRequirement('_entity_access', 'help_topic.unlock');
+ $collection->add('entity.help_topic.unlock_form', $route);
+
+ $route = (new Route('/admin/config/development/help/manage/{help_topic}/lock'))
+ ->setDefaults([
+ '_entity_form' => 'help_topic.lock',
+ '_title' => 'Lock help topic',
+ ])
+ ->setRequirement('_entity_access', 'help_topic.lock');
+ $collection->add('entity.help_topic.lock_form', $route);
+
+ return $collection;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getCanonicalRoute(EntityTypeInterface $entity_type) {
+ $route = (new Route('/admin/help-topic/{help_topic}'))
+ ->setDefaults([
+ '_entity_view' => 'help_topic.full',
+ '_title' => 'Help',
+ ])
+ ->setRequirement('_entity_access', 'help_topic.view')
+ ->setOption('parameters', [
+ 'help_topic' => [
+ 'type' => 'entity:help_topic',
+ // Force load in current interface language.
+ 'with_config_overrides' => TRUE,
+ ],
+ ]);
+
+ return $route;
+ }
+
+}
diff --git a/core/modules/config_help/src/HelpViewBuilder.php b/core/modules/config_help/src/HelpViewBuilder.php
new file mode 100644
index 0000000..17ffb65
--- /dev/null
+++ b/core/modules/config_help/src/HelpViewBuilder.php
@@ -0,0 +1,150 @@
+entityTypeId = $entity_type->id();
+ $this->entityType = $entity_type;
+ // Parent class is using deprecated entity manager.
+ $this->entityManager = $entity_manager;
+ $this->helpStorage = $entity_manager->getStorage('help_topic');
+ $this->languageManager = $language_manager;
+ $this->token = $token;
+ $this->themeRegistry = $theme_registry;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+ return new static($entity_type,
+ $container->get('entity.manager'),
+ $container->get('language_manager'),
+ $container->get('token'),
+ $container->get('theme.registry')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function viewMultiple(array $entities = [], $view_mode = 'full', $langcode = NULL) {
+ $output = [];
+
+ /** @var \Drupal\config_help\HelpTopicInterface[] $entities */
+ foreach ($entities as $entity_id => $help_topic) {
+ // Get the cache information and other build defaults.
+ $build = $this->getBuildDefaults($help_topic, $view_mode);
+ $build['#langcode'] = $langcode;
+ $build['#title'] = $help_topic->label();
+
+ // Add in the body.
+ $collections = $help_topic->getPluginCollections();
+ /** @var \Drupal\config_help\TextSectionPluginCollection $body_collection */
+ $body_collection = $collections['body'];
+ $build['#body'] = $body_collection->buildRenderArray([$this, 'processBodyItem']);
+
+ // Figure out which topics to list as related, including topics this
+ // entity lists as related, plus topics that have said "Add me to this
+ // topic's related list" using the list on field.
+ $related = $help_topic->getRelated() +
+ $this->helpStorage->getQuery()
+ ->condition('list_on.*', $help_topic->id())
+ ->execute();
+
+ $links = [];
+
+ foreach ($related as $other_id) {
+ if ($other_id != $help_topic->id()) {
+ $topic = $this->helpStorage->load($other_id);
+ if ($topic) {
+ $links[$other_id] = [
+ 'title' => $topic->label(),
+ 'url' => $topic->urlInfo('canonical'),
+ ];
+ }
+ }
+ }
+
+ if (count($links)) {
+ ksort($links);
+ $build['#related'] = [
+ '#theme' => 'links',
+ '#heading' => [
+ 'text' => $this->t('Related topics'),
+ 'level' => 'h2',
+ ],
+ '#links' => $links,
+ ];
+ }
+
+ $output[$entity_id] = $build;
+ }
+
+ return $output;
+ }
+
+ /**
+ * Processes a piece of text from the body.
+ *
+ * @param array $item
+ * Array containing the 'value' and 'format' elements from a single
+ * paragraph-sized piece of the body.
+ *
+ * @return array
+ * Render array section for this piece of the body, with tokens replaced.
+ */
+ public function processBodyItem(array $item) {
+ $bubbleable_metadata = new BubbleableMetadata();
+ $section = [
+ '#type' => 'processed_text',
+ '#text' => $this->token->replace($item['value'], [], [], $bubbleable_metadata),
+ '#format' => $item['format'],
+ ];
+ $bubbleable_metadata->applyTo($section);
+ return $section;
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/HelpSection/ConfigHelpSection.php b/core/modules/config_help/src/Plugin/HelpSection/ConfigHelpSection.php
new file mode 100644
index 0000000..f6a4dc8
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/HelpSection/ConfigHelpSection.php
@@ -0,0 +1,96 @@
+entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get('entity_type.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheTags() {
+ // The list of topics depends on the list cache tag for the topic entity.
+ return $this->entityTypeManager->getDefinition('help_topic')->getListCacheTags();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheContexts() {
+ // The links are checked for user access, so we need the user permissions
+ // context.
+ return ['user.permissions'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function listTopics() {
+ /** @var \Drupal\Core\Entity\EntityStorageInterface $tour_storage */
+ $help_storage = $this->entityTypeManager->getStorage('help_topic');
+ /** @var \Drupal\config_help\Entity\HelpTopic[] $entities */
+ $entities = $help_storage->loadMultiple();
+ uasort($entities, [HelpTopic::class, 'sort']);
+
+ $topics = [];
+ foreach ($entities as $entity) {
+ if ($entity->isTopLevel() && $entity->access('view')) {
+ $topics[$entity->id()] = $entity->toLink();
+ }
+ }
+
+ return $topics;
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/BulletItem.php b/core/modules/config_help/src/Plugin/TextSection/BulletItem.php
new file mode 100644
index 0000000..035d310
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/BulletItem.php
@@ -0,0 +1,39 @@
+ 'item_list',
+ '#list_type' => 'ul',
+ '#items' => [],
+ ];
+
+ $group['#items'][] = $this->getInnerBuild();
+
+ return $group;
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/Code.php b/core/modules/config_help/src/Plugin/TextSection/Code.php
new file mode 100644
index 0000000..d77d32d
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/Code.php
@@ -0,0 +1,26 @@
+ '',
+ '#suffix' => '
',
+ ] + parent::getInnerBuild();
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/DescriptionName.php b/core/modules/config_help/src/Plugin/TextSection/DescriptionName.php
new file mode 100644
index 0000000..70e8362
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/DescriptionName.php
@@ -0,0 +1,53 @@
+ '',
+ '#suffix' => '',
+ ] + parent::getInnerBuild();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function groupWith() {
+ return ['description_name', 'description_value'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addToGroup(array $group) {
+ // Initialize the group, in case this is the first item.
+ $group += [
+ '#prefix' => '',
+ '#suffix' => '
',
+ 'list' => [],
+ ];
+
+ $group['list'][] = $this->getInnerBuild();
+
+ return $group;
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/DescriptionValue.php b/core/modules/config_help/src/Plugin/TextSection/DescriptionValue.php
new file mode 100644
index 0000000..0f6986c
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/DescriptionValue.php
@@ -0,0 +1,53 @@
+ '',
+ '#suffix' => '',
+ ] + parent::getInnerBuild();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function groupWith() {
+ return ['description_name', 'description_value'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addToGroup(array $group) {
+ // Initialize the group, in case this is the first item.
+ $group += [
+ '#prefix' => '',
+ '#suffix' => '
',
+ 'list' => [],
+ ];
+
+ $group['list'][] = $this->getInnerBuild();
+
+ return $group;
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/Heading.php b/core/modules/config_help/src/Plugin/TextSection/Heading.php
new file mode 100644
index 0000000..6583917
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/Heading.php
@@ -0,0 +1,26 @@
+ '',
+ '#suffix' => '
',
+ ] + parent::getInnerBuild();
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/NumberedItem.php b/core/modules/config_help/src/Plugin/TextSection/NumberedItem.php
new file mode 100644
index 0000000..518a052
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/NumberedItem.php
@@ -0,0 +1,39 @@
+ 'item_list',
+ '#list_type' => 'ol',
+ '#items' => [],
+ ];
+
+ $group['#items'][] = $this->getInnerBuild();
+
+ return $group;
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/Paragraph.php b/core/modules/config_help/src/Plugin/TextSection/Paragraph.php
new file mode 100644
index 0000000..33752cc
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/Paragraph.php
@@ -0,0 +1,26 @@
+ '',
+ '#suffix' => '
',
+ ] + parent::getInnerBuild();
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/SubHeading.php b/core/modules/config_help/src/Plugin/TextSection/SubHeading.php
new file mode 100644
index 0000000..95aa972
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/SubHeading.php
@@ -0,0 +1,26 @@
+ '',
+ '#suffix' => '
',
+ ] + parent::getInnerBuild();
+ }
+
+}
diff --git a/core/modules/config_help/src/Plugin/TextSection/TextSectionBase.php b/core/modules/config_help/src/Plugin/TextSection/TextSectionBase.php
new file mode 100644
index 0000000..03270f5
--- /dev/null
+++ b/core/modules/config_help/src/Plugin/TextSection/TextSectionBase.php
@@ -0,0 +1,55 @@
+ $text];
+ }
+ $this->text = $text;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getInnerBuild() {
+ return $this->text;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function groupWith() {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addToGroup(array $group) {
+ return [];
+ }
+
+}
diff --git a/core/modules/config_help/src/TextSectionManager.php b/core/modules/config_help/src/TextSectionManager.php
new file mode 100644
index 0000000..bbda01a
--- /dev/null
+++ b/core/modules/config_help/src/TextSectionManager.php
@@ -0,0 +1,96 @@
+alterInfo('text_section_info');
+ $this->setCacheBackend($cache_backend, 'text_section_plugins');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function findDefinitions() {
+ $definitions = parent::findDefinitions();
+ uasort($definitions, [$this, 'sortPlugins']);
+ return $definitions;
+ }
+
+ /**
+ * Makes an options array for a select list of text section plugins.
+ *
+ * @return array
+ * Array whose keys are machine names of text section plugins, and whose
+ * values are the translated labels for the plugins, suitable for use as
+ * options for radios or select lists.
+ */
+ public function getOptionsList() {
+ $plugins = $this->getDefinitions();
+ $options = [];
+ foreach ($plugins as $plugin_id => $plugin_definition) {
+ $options[$plugin_id] = $plugin_definition['label'];
+ }
+
+ return $options;
+ }
+
+ /**
+ * Provides a uasort() callback for sorting plugin definitions.
+ *
+ * Sorts by weight first, and within the same weight, alphabetically by
+ * label.
+ *
+ * @param array $definition1
+ * First plugin definition to compare.
+ * @param array $definition2
+ * Second plugin definition to compare.
+ *
+ * @return int
+ * A number <1 if the first plugin should come first, >1 if the second
+ * should, and 0 if this cannot be determined.
+ */
+ protected function sortPlugins(array $definition1, array $definition2) {
+ if ($definition1['weight'] < $definition2['weight']) {
+ return -1;
+ }
+ if ($definition2['weight'] < $definition1['weight']) {
+ return 1;
+ }
+ if ($definition1['label'] < $definition2['label']) {
+ return -1;
+ }
+ if ($definition2['label'] < $definition1['label']) {
+ return 1;
+ }
+ return 0;
+ }
+
+}
diff --git a/core/modules/config_help/src/TextSectionPluginCollection.php b/core/modules/config_help/src/TextSectionPluginCollection.php
new file mode 100644
index 0000000..4ad83a0
--- /dev/null
+++ b/core/modules/config_help/src/TextSectionPluginCollection.php
@@ -0,0 +1,130 @@
+ 'text_sections',
+ '#sections' => [],
+ ];
+
+ $configuration = $this->getConfiguration();
+ if (!$configuration) {
+ return $build;
+ }
+
+ foreach ($configuration as $key => $item) {
+ $section = [
+ '#section_type' => $item['id'],
+ '#text' => $item['text'],
+ ];
+ if ($callback) {
+ $section['#text'] = call_user_func($callback, $section['#text']);
+ }
+ $build['#sections'][] = $section;
+ }
+
+ return $build;
+ }
+
+ /**
+ * Adds plugins to the collection from a text_sections render array.
+ *
+ * @param array $element
+ * Render array element of #type 'text_sections' to add plugins from.
+ *
+ * @return $this
+ *
+ * @see \Drupal\config_help\TextSectionPluginCollection::buildRenderArray()
+ */
+ public function addFromRenderArray(array $element) {
+ $sections = $element['#sections'];
+ foreach ($sections as $index => $section) {
+ $this->addInstanceId($index, [
+ 'id' => $section['#section_type'],
+ 'text' => $section['#text'],
+ ]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Pre-renders the current collection.
+ *
+ * @param \Drupal\Core\Render\Renderer $renderer
+ * The renderer service to use.
+ * @param array $element
+ * The render array to add information to.
+ *
+ * @return array
+ * Render array: starts with $element, and adds the formatted text from the
+ * plugins.
+ */
+ public function preRenderCollection(Renderer $renderer, array $element) {
+ $previous_type = '';
+ $group_build = [];
+ $element['text_sections'] = [];
+
+ $all_configuration = $this->getConfiguration();
+ foreach ($all_configuration as $id => $config) {
+ /** @var \Drupal\config_help\TextSectionPluginInterface $plugin */
+ $plugin = $this->get($id);
+ $plugin->setText($config['text']);
+ $renderer->addCacheableDependency($element, $plugin);
+
+ // Check for grouping.
+ $this_group_with = $plugin->groupWith();
+
+ if (!in_array($previous_type, $this_group_with) && !empty($group_build)) {
+ // This section should not be grouped with the previous one, so add the
+ // previous grouping to the element and reset the in-progress group.
+ $element['text_sections'][] = $group_build;
+ $group_build = [];
+ }
+
+ if (!empty($this_group_with)) {
+ // This is a grouped section, either for a new group or a compatible
+ // group. Add this to the group, or initialize a new group.
+ $group_build = $plugin->addToGroup($group_build);
+ }
+ else {
+ // It is not grouped, so just add it to the element.
+ $element['text_sections'][] = $plugin->getInnerBuild();
+ }
+
+ // Save this type for the next grouping decision.
+ $previous_type = $plugin->getPluginId();
+ }
+
+ // Add the last in-progress group to the element if there is one.
+ if (!empty($group_build)) {
+ $element['text_sections'][] = $group_build;
+ }
+
+ return $element;
+ }
+
+}
diff --git a/core/modules/config_help/src/TextSectionPluginInterface.php b/core/modules/config_help/src/TextSectionPluginInterface.php
new file mode 100644
index 0000000..d69507b
--- /dev/null
+++ b/core/modules/config_help/src/TextSectionPluginInterface.php
@@ -0,0 +1,77 @@
+
+ {{ body }}
+ {{ related }}
+
diff --git a/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test.yml b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test.yml
new file mode 100644
index 0000000..0aedb26
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+ enforced:
+ module:
+ - config_help_test
+ theme: { }
+id: help_test
+label: 'ABC Help Test module'
+top_level: true
+locked: false
+related:
+ - config_help
+ - help_test_linked
+list_on: { }
+body:
+ -
+ id: paragraph
+ text:
+ value: 'This is a test. It should link to the Help module topic, and it should link to the help admin page. Also there should be a related topic link below to the Help module topic page and the linked topic.'
+ format: help
diff --git a/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_additional.yml b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_additional.yml
new file mode 100644
index 0000000..8ce3949
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_additional.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+ enforced:
+ module:
+ - config_help_test
+ theme: { }
+id: help_test_additional
+label: 'Additional topic'
+top_level: false
+locked: false
+related: { }
+list_on:
+ - help_test
+body:
+ -
+ id: paragraph
+ text:
+ value: 'This topic should get listed automatically on the Help test topic.'
+ format: help_plain
diff --git a/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_linked.yml b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_linked.yml
new file mode 100644
index 0000000..1f7f422
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_linked.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ enforced:
+ module:
+ - config_help_test
+ theme: { }
+id: help_test_linked
+label: 'Linked topic'
+top_level: false
+locked: false
+related: { }
+list_on: { }
+body:
+ -
+ id: paragraph
+ text:
+ value: 'This topic is not supposed to be top-level.'
+ format: help
diff --git a/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_locked.yml b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_locked.yml
new file mode 100644
index 0000000..23a3101
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_locked.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ enforced:
+ module:
+ - config_help_test
+ theme: { }
+id: help_test_locked
+label: 'Locked topic'
+top_level: false
+locked: true
+related: { }
+list_on: { }
+body:
+ -
+ id: paragraph
+ text:
+ value: 'This topic is supposed to be locked to editing.'
+ format: help
diff --git a/core/modules/config_help/tests/modules/config_help_test/config_help_test.info.yml b/core/modules/config_help/tests/modules/config_help_test/config_help_test.info.yml
new file mode 100644
index 0000000..155c43d
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config_help_test.info.yml
@@ -0,0 +1,7 @@
+# The name of this module is deliberately different from its machine
+# name to test the presented order of help topics.
+name: 'ABC Help Test'
+type: module
+description: 'Support module for help testing.'
+package: Testing
+core: 8.x
diff --git a/core/modules/config_help/tests/modules/config_help_test/config_help_test.module b/core/modules/config_help/tests/modules/config_help_test/config_help_test.module
new file mode 100644
index 0000000..2cc5143
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config_help_test.module
@@ -0,0 +1,18 @@
+adminUser = $this->createUser([
+ 'access administration pages',
+ 'administer help topics',
+ 'use text format help',
+ 'view help topics',
+ 'administer help topic locking',
+ ]);
+ $this->nonLockingUser = $this->createUser([
+ 'access administration pages',
+ 'administer help topics',
+ 'use text format help',
+ 'view help topics',
+ ]);
+ $this->nonAdminUser = $this->createUser(['access administration pages']);
+
+ // Make sure page title, help, and local tasks are showing.
+ $this->placeBlock('local_tasks_block');
+ $this->placeBlock('local_actions_block');
+ $this->placeBlock('page_title_block');
+ $this->placeBlock('help_block');
+ }
+
+ /**
+ * Logs in users, tests help admin pages.
+ */
+ public function testHelpAdmin() {
+ $this->drupalLogin($this->nonLockingUser);
+ $this->verifyHelpLockingAdmin(403);
+
+ $this->drupalLogin($this->adminUser);
+ $this->verifyHelpLockingAdmin();
+ $this->verifyHelpAdmin();
+
+ $this->drupalLogin($this->nonAdminUser);
+ $this->verifyHelpAdmin(403);
+ }
+
+ /**
+ * Verifies the logged in user has the correct access to locking admin.
+ *
+ * @param int $response
+ * (optional) The HTTP response code to test for. If it's 200 (default),
+ * the test verifies the user has access; if it's not, it verifies they
+ * are denied access. Note: Generic admin access for help topics is assumed
+ * but not verified in this method.
+ */
+ protected function verifyHelpLockingAdmin($response = 200) {
+ $this->drupalGet('admin/config/development/help');
+
+ // Verify that a user without locking permissions cannot lock or unlock
+ // any topic, and that a user with the permission can get to the forms,
+ // but only to lock an unlocked topic and vice versa.
+ $pages = [
+ 'admin/config/development/help/manage/help_test/lock' => TRUE,
+ 'admin/config/development/help/manage/help_test_locked/lock' => FALSE,
+ 'admin/config/development/help/manage/help_test/unlock' => FALSE,
+ 'admin/config/development/help/manage/help_test_locked/unlock' => TRUE,
+ ];
+ foreach ($pages as $page => $allowed) {
+ $this->drupalGet($page);
+ $session = $this->assertSession();
+ if (!$allowed) {
+ $session->statusCodeEquals(403);
+ }
+ else {
+ $session->statusCodeEquals($response);
+ }
+ }
+
+ // Return at this point if testing for 403.
+ if ($response == 403) {
+ return;
+ }
+
+ // Verify that locked pages cannot be edited or deleted. Editing and
+ // deleting of unlocked pages is tested elsewhere. Note that the URL
+ // for editing has no suffix.
+ foreach (['', '/delete'] as $action) {
+ $this->drupalGet('admin/config/development/help/manage/help_test_locked' . $action);
+ $session = $this->assertSession();
+ $session->statusCodeEquals(403);
+ }
+
+ // Unlock the page, and verify it can then be edited/deleted, but not
+ // unlocked.
+ $this->drupalGet('admin/config/development/help/manage/help_test_locked/unlock');
+ $session = $this->assertSession();
+ $session->pageTextContains('Locked topics are typically provided by modules');
+ $session->pageTextContains('Are you sure you want to unlock the topic');
+ $session->pageTextContains('Locked topic');
+ $this->drupalPostForm(NULL, [], 'Unlock');
+ $session = $this->assertSession();
+ $session->pageTextContains('Unlocked help topic');
+ // The '' action is actually edit.
+ foreach (['', '/delete', '/lock'] as $action) {
+ $this->drupalGet('admin/config/development/help/manage/help_test_locked' . $action);
+ $session = $this->assertSession();
+ $session->statusCodeEquals(200);
+ }
+ $this->drupalGet('admin/config/development/help/manage/help_test_locked/unlock');
+ $session = $this->assertSession();
+ $session->statusCodeEquals(403);
+
+ // Lock it up again, and verify again.
+ $this->drupalGet('admin/config/development/help/manage/help_test_locked/lock');
+ $session = $this->assertSession();
+ $session->pageTextContains('Locked topics cannot be edited or deleted until they are unlocked');
+ $session->pageTextContains('Are you sure you want to lock the topic');
+ $session->pageTextContains('Locked topic');
+ $this->drupalPostForm(NULL, [], 'Lock');
+ $session = $this->assertSession();
+ $session->pageTextContains('Locked help topic');
+ // The '' action is actually edit.
+ foreach (['', '/delete', '/lock'] as $action) {
+ $this->drupalGet('admin/config/development/help/manage/help_test_locked' . $action);
+ $session = $this->assertSession();
+ $session->statusCodeEquals(403);
+ }
+ $this->drupalGet('admin/config/development/help/manage/help_test_locked/unlock');
+ $session = $this->assertSession();
+ $session->statusCodeEquals(200);
+ }
+
+ /**
+ * Verifies the logged in user has the correct access to help admin.
+ *
+ * @param int $response
+ * (optional) The HTTP response code to test for. If it's 200 (default),
+ * the test verifies the user has access; if it's not, it verifies they
+ * are denied access.
+ */
+ protected function verifyHelpAdmin($response = 200) {
+ // Verify admin links.
+ foreach ([
+ 'admin/config',
+ 'admin/config/development',
+ 'admin/index',
+ ] as $page) {
+ $this->drupalGet($page);
+ $session = $this->assertSession();
+ if ($response == 200) {
+ $session->pageTextContains('Add, delete, and edit help topics');
+ $session->linkExists('Help topics');
+ }
+ else {
+ $session->pageTextNotContains('Add, delete, and edit help topics');
+ $session->linkNotExists('Help topics');
+ }
+ }
+
+ // Verify CRUD and listing page.
+ $this->drupalGet('admin/config/development/help');
+ $session = $this->assertSession();
+ $session->statusCodeEquals($response);
+ if ($response == 200) {
+ $session->linkExists('Add new help topic');
+ $session->pageTextContains('Help topics');
+ $session->pageTextContains('Title');
+ $session->pageTextContains('Machine name');
+ $session->pageTextContains('Operations');
+ }
+
+ $this->drupalGet('admin/config/development/help/add');
+ $session = $this->assertSession();
+ $session->statusCodeEquals($response);
+
+ // Verify autocomplete page.
+ $this->drupalGet('config-help/autocomplete-topic');
+ $session = $this->assertSession();
+ $session->statusCodeEquals($response);
+
+ // Everything after this point, just do for the admin user.
+ if ($response != 200) {
+ return;
+ }
+
+ // Create a new help topic from the UI.
+ $body = 'This text is for the foo topic';
+ $title = 'Foo topic';
+ $id = 'foo';
+ $this->drupalPostForm('admin/config/development/help/add', [
+ 'label' => $title,
+ 'id' => $id,
+ 'top_level' => TRUE,
+ 'body[0][text][value]' => $body,
+ ], 'Save');
+ $session = $this->assertSession();
+ $session->pageTextContains('Help topic added');
+
+ // Click to view the topic and verify the edit link works too.
+ $this->clickLink($title);
+ $session = $this->assertSession();
+ $session->pageTextContains($title);
+ $session->pageTextContains($body);
+
+ $this->clickLink('Edit');
+ $new_title = 'Foo longer topic';
+ $new_id = 'foo2';
+ $this->drupalPostForm(NULL, [
+ 'label' => $new_title,
+ 'id' => $new_id,
+ ], 'Save');
+ $session = $this->assertSession();
+ $session->pageTextContains('Help topic updated');
+ $session->linkExists($new_title);
+
+ // Test a few autocomplete values.
+ $autocompletes = [
+ // ID of a topic we just added.
+ $new_id => [$new_title, $new_id],
+ // Title word of a topic we just edited.
+ 'longer' => [$new_title, $new_id],
+ 'help_test' => [
+ 'Additional topic',
+ 'help_test_additional',
+ 'Help Test module',
+ ],
+ ];
+
+ foreach ($autocompletes as $query => $texts) {
+ $this->drupalGet('config-help/autocomplete-topic', ['query' => ['q' => $query]]);
+ $session = $this->assertSession();
+ $session->statusCodeEquals($response);
+ foreach ($texts as $text) {
+ $session->responseContains($text);
+ }
+ }
+
+ // Verify the link is on the Help page.
+ $this->drupalGet('admin/help');
+ $session = $this->assertSession();
+ $session->linkExists($new_title);
+
+ // Test deleting.
+ $this->drupalGet('admin/config/development/help/manage/' . $new_id . '/delete');
+ $session = $this->assertSession();
+ $session->pageTextContains('This action cannot be undone.');
+ $session->pageTextContains('Are you sure you want to delete the help topic');
+ $session->pageTextContains($new_title);
+ $this->drupalPostForm(NULL, [], 'Delete');
+ $session = $this->assertSession();
+ $session->pageTextContains('The help topic');
+ $session->pageTextContains('has been deleted.');
+ $session->pageTextContains($new_title);
+ // Verfiy we are back on the admin page and there is no longer a link
+ // to the topic we just deleted.
+ $session->linkExists('Add new help topic');
+ $session = $this->assertSession();
+ $session->pageTextContains('Help topics');
+ $session->pageTextContains('Title');
+ $session->pageTextContains('Machine name');
+ $session->pageTextContains('Operations');
+ $session->linkNotExists($new_title);
+ // Verify the topic is not on admin/help any more either.
+ $this->drupalGet('admin/help');
+ $session = $this->assertSession();
+ $session->linkNotExists($new_title);
+
+ // Test form validation.
+ $this->drupalGet('admin/config/development/help/add');
+ $this->drupalPostForm(NULL, [
+ 'label' => $title,
+ 'id' => 'foobar',
+ 'top_level' => TRUE,
+ 'body[0][text][value]' => $body,
+ 'related' => 'invalid.text',
+ ], 'Save');
+ $session = $this->assertSession();
+ $session->pageTextNotContains('Help topic added');
+ $session->pageTextContains('Must be a comma-separated list of existing topic machine names');
+
+ // Fix form and add dependency field.
+ $this->drupalPostForm(NULL, [
+ 'related' => '',
+ 'modules' => 'color',
+ ], 'Save');
+ $session = $this->assertSession();
+ $session->pageTextContains('Help topic added');
+ $session->linkExists($title);
+ $this->drupalGet('admin/help');
+ $session = $this->assertSession();
+ $session->linkExists($title);
+
+ // Disable the color module and verify dependent topics go away.
+ $this->container->get('module_installer')->uninstall(['color']);
+ $this->drupalGet('admin/help');
+ $session = $this->assertSession();
+ $session->linkExists('Building a help system');
+ $session->linkNotExists($title);
+ }
+
+ /**
+ * Tests tabs on topic view page.
+ */
+ public function testTabsVisible() {
+ // Install config translation module to test translation tab.
+ $this->container->get('module_installer')
+ ->install(['config_translation', 'locale', 'language']);
+ ConfigurableLanguage::createFromLangcode('es')->save();
+
+ $user = $this->createUser([
+ 'access administration pages',
+ 'administer help topics',
+ 'use text format help',
+ 'view help topics',
+ 'administer help topic locking',
+ 'translate configuration',
+ ]);
+ $this->drupalLogin($user);
+ foreach (['View', 'Edit', 'Delete', 'Translate help topic'] as $label) {
+ $this->drupalGet('admin/help-topic/help_test');
+ $this->clickLink($label);
+ $session = $this->assertSession();
+ $session->statusCodeEquals(200);
+ }
+ }
+
+ /**
+ * Tests autocomplete for topics, modules & themes.
+ */
+ public function testAutocomplete() {
+ $path = 'config-help/autocomplete-';
+ $cases = [
+ 'topic' => [
+ '|' => '[]',
+ 'Writing good' => '[{"value":"config_help_writing","label":"Writing good help (config_help_writing)"}]',
+ 'config_' => '[{"value":"config_help","label":"Building a help system (config_help)"},{"value":"config_basic","label":"Changing basic site settings (config_basic)"},{"value":"config_error","label":"Configuring error responses, including 403\/404 pages (config_error)"},{"value":"config_help_form","label":"Help topic editing form (config_help_form)"},{"value":"config_help_writing","label":"Writing good help (config_help_writing)"}]',
+ ],
+ 'module' => [
+ '|' => '[]',
+ 'ABC' => '[{"value":"config_help_test","label":"ABC Help Test (config_help_test)"}]',
+ 'config' => '[{"value":"config_help","label":"Configurable Help (config_help)"},{"value":"config_help_test","label":"ABC Help Test (config_help_test)"}]',
+ ],
+ 'theme' => [
+ '|' => '[]',
+ 'Stab' => '[{"value":"stable","label":"Stable (stable)"}]',
+ 'a' => '[{"value":"stable","label":"Stable (stable)"},{"value":"classy","label":"Classy (classy)"}]',
+ ],
+ ];
+ // Test that non-admin user has no access to autocomplete routes.
+ $this->drupalLogin($this->nonAdminUser);
+ foreach (array_keys($cases) as $part) {
+ $this->drupalGet($path . $part);
+ $session = $this->assertSession();
+ $session->statusCodeEquals(403);
+ }
+ // Test output of autocomplete.
+ $this->drupalLogin($this->adminUser);
+ foreach ($cases as $part => $test) {
+ foreach ($test as $query => $expected) {
+ $this->drupalGet($path . $part, ['query' => ['q' => $query]]);
+ $session = $this->assertSession();
+ $session->statusCodeEquals(200);
+ $this->assertEquals($expected, $this->getSession()->getPage()->getContent());
+ }
+ }
+ // Make sure results are limited.
+ $this->drupalGet($path . 'topic', ['query' => ['q' => 'o']]);
+ $matches = Json::decode($this->getSession()->getPage()->getContent());
+ $this->assertEquals(10, count($matches));
+ $this->container->get('module_installer')
+ ->install(['contextual', 'dblog', 'hal', 'field', 'link']);
+ system_rebuild_module_data();
+ $this->drupalGet($path . 'module', ['query' => ['q' => 'l']]);
+ $matches = Json::decode($this->getSession()->getPage()->getContent());
+ $this->assertEquals(10, count($matches));
+ }
+
+}
diff --git a/core/modules/config_help/tests/src/Functional/HelpTopicTest.php b/core/modules/config_help/tests/src/Functional/HelpTopicTest.php
new file mode 100644
index 0000000..1276884
--- /dev/null
+++ b/core/modules/config_help/tests/src/Functional/HelpTopicTest.php
@@ -0,0 +1,300 @@
+install(['seven']);
+ \Drupal::service('config.factory')->getEditable('system.theme')->set('admin', 'seven')->save();
+
+ // Place various blocks.
+ $settings = [
+ 'theme' => 'seven',
+ 'region' => 'help',
+ ];
+ $this->placeBlock('help_block', $settings);
+ $this->placeBlock('local_tasks_block', $settings);
+ $this->placeBlock('local_actions_block', $settings);
+ $this->placeBlock('page_title_block', $settings);
+
+ // Create users.
+ $this->adminUser = $this->createUser([
+ 'access administration pages',
+ 'view the administration theme',
+ 'administer permissions',
+ 'administer help topics',
+ 'view help topics',
+ ]);
+ $this->anyUser = $this->createUser([]);
+ }
+
+ /**
+ * Tests the main help page and individual pages for topics.
+ */
+ public function testHelp() {
+ // Log in the regular user.
+ $this->drupalLogin($this->anyUser);
+ $this->verifyHelp(403, FALSE);
+
+ // Log in the admin user.
+ $this->drupalLogin($this->adminUser);
+ $this->verifyHelp();
+ $this->verifyHelpLinks();
+
+ // Verify that help topics text appears on admin/help.
+ $this->drupalGet('admin/help');
+ $session = $this->assertSession();
+ $session->responseContains('Configured topics
');
+ $session->pageTextContains('Configured topics can be provided by modules, themes');
+
+ // Verify the cache tag for the list of topics is present, as well as
+ // the cache context for user permissions.
+ $this->assertCacheTag('config:help_topic_list');
+ $this->assertCacheContext('user.permissions');
+
+ // Verify links for for configurable topics, and order.
+ $page_text = $this->getTextContent();
+ $start = strpos($page_text, 'Configured topics');
+ $pos = $start;
+ foreach ($this->getTopicList() as $info) {
+ $name = $info['name'];
+ $session->linkExists($name);
+ $new_pos = strpos($page_text, $name, $start);
+ $this->assertTrue($new_pos > $pos, 'Order of ' . $name . ' is correct on page');
+ $pos = $new_pos;
+ }
+
+ // Uninstall the test module and verify the topics are gone, after
+ // reloading page.
+ $this->container->get('module_installer')->uninstall(['config_help_test']);
+ $this->drupalGet('admin/help');
+ $session = $this->assertSession();
+ $session->linkNotExists('ABC Help Test module');
+ $session->linkNotExists('ABC Help Test');
+ }
+
+ /**
+ * Tests export and import of help topics, and topic create from array.
+ */
+ public function testTopicExportImport() {
+ // Create a help topic entity.
+ $values = [
+ 'id' => 'foo',
+ 'label' => 'Foo',
+ 'body' => [
+ [
+ 'type' => 'heading',
+ 'text' => 'Greetings',
+ ],
+ [
+ 'type' => 'paragraph',
+ 'text' => 'Hello, world!',
+ ],
+ ],
+ 'top_level' => TRUE,
+ 'locked' => TRUE,
+ 'related' => ['config_help'],
+ 'list_on' => ['config_help_form'],
+ ];
+ // Set the dependecies after creation, because it's an odd bit of the
+ // configuration.
+ $dependencies = [
+ 'module' => ['config_help', 'filter'],
+ 'theme' => ['classy', 'bartik'],
+ ];
+
+ /** @var \Drupal\config_help\HelpTopicInterface $foo */
+ $foo = HelpTopic::create($values);
+ $foo->setEnforcedDependencies($dependencies);
+
+ // Export and import the topic.
+ $foo_export = Yaml::encode($foo->toArray());
+ $bar = HelpTopic::create(Yaml::decode($foo_export));
+
+ // Verify that everything is OK.
+ $to_check = [
+ // This is an array of method name => component in $values, except
+ // dependencies, which are checked separately.
+ 'id' => 'id',
+ 'label' => 'label',
+ 'getBody' => 'body',
+ 'isTopLevel' => 'top_level',
+ 'isLocked' => 'locked',
+ 'getRelated' => 'related',
+ 'getListOn' => 'list_on',
+ 'getEnforcedDependencies' => FALSE,
+ ];
+
+ // Verify that the initial create got the right values, and that after
+ // export/import, the values are the same.
+ foreach ($to_check as $method => $component) {
+ if ($component) {
+ $this->assertEqual(call_user_func([$foo, $method]), $values[$component], 'Data for ' . $component . ' is the same as method ' . $method);
+ }
+ $this->assertEqual(call_user_func([$foo, $method]), call_user_func([$bar, $method]), 'Method ' . $method . ' is the same before and after export/import');
+ }
+ $this->assertEqual($foo->getEnforcedDependencies(), $dependencies, 'Data for dependencies is the same as method getEnforcedDependencies');
+
+ }
+
+ /**
+ * Verifies the logged in user has access to various help links and pages.
+ *
+ * @param int $response
+ * (optional) The HTTP response code to test for. If it's 200 (default),
+ * the test verifies the user sees the help; if it's not, it verifies they
+ * are denied access.
+ * @param bool $check_tags
+ * (optional) TRUE (default) to verify that the cache tags are on the page,
+ * even though the response is not a 200. Cache tags should be there except
+ * for users who don't even have 'view help topics' permission.
+ */
+ protected function verifyHelp($response = 200, $check_tags = TRUE) {
+ // Verify access to configurable help topic pages.
+ foreach ($this->getTopicList() as $topic => $info) {
+ // View help topic page.
+ $this->drupalGet('admin/help-topic/' . $topic);
+ $session = $this->assertSession();
+ $session->statusCodeEquals($response);
+ if ($response == 200) {
+ $name = $info['name'];
+ $session->titleEquals($name . ' | Drupal');
+ $session->responseContains('' . $name . '
');
+ // Check the cache tags.
+ foreach ($info['cache_tags'] as $tag) {
+ $this->assertCacheTag($tag);
+ }
+ }
+ elseif ($check_tags) {
+ // Check that the cache tags are there.
+ foreach ($info['cache_tags'] as $tag) {
+ $this->assertCacheTag($tag);
+ }
+ }
+ }
+ }
+
+ /**
+ * Verifies links on the test help topic page and other pages.
+ *
+ * Assumes an admin user is logged in.
+ */
+ protected function verifyHelpLinks() {
+ // Verify links on the test top-level page.
+ $page = 'admin/help-topic/help_test';
+ $links = [
+ 'link to the Help module topic' => 'Building a help system',
+ 'link to the help admin page' => 'Add new help topic',
+ 'Building a help system' => 'Building a help system',
+ 'Linked topic' => 'This topic is not supposed to be top-level',
+ 'Additional topic' => 'This topic should get listed automatically',
+ ];
+ foreach ($links as $link_text => $page_text) {
+ $this->drupalGet($page);
+ $this->clickLink($link_text);
+ $session = $this->assertSession();
+ $session->pageTextContains($page_text);
+ }
+
+ // Verify that the non-top-level topics do not appear on the Help page.
+ $this->drupalGet('admin/help');
+ $session = $this->assertSession();
+ $session->linkNotExists('Linked topic');
+ $session->linkNotExists('Additional topic');
+ }
+
+ /**
+ * Gets a list of topic IDs to test.
+ *
+ * @return array
+ * A list of topics to test, in the order in which they should appear. The
+ * keys are the machine names of the topics. The values are arrays with the
+ * following elements:
+ * - name: Displayed name.
+ * - cache_tags: Cache tags to verify are present on the topic display page.
+ */
+ protected function getTopicList() {
+ return [
+ 'help_test' => [
+ 'name' => 'ABC Help Test module',
+ 'cache_tags' => [
+ 'config:config_help.topic.help_test',
+ 'config:config_help.topic.config_help',
+ 'config:config_help.topic.help_test_linked',
+ ],
+ ],
+ 'config_help' => [
+ 'name' => 'Building a help system',
+ 'cache_tags' => [
+ 'config:config_help.topic.config_help',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Asserts that a given cache context exists.
+ *
+ * This was part of the Simpletest base class, but doesn't exist in
+ * BrowserTestBase.
+ */
+ protected function assertCacheContext($expected_cache_context) {
+ $contexts = explode(' ', $this->getSession()->getResponseHeader('X-Drupal-Cache-Contexts'));
+ $this->assertTrue(in_array($expected_cache_context, $contexts), "'" . $expected_cache_context . "' is present in the X-Drupal-Cache-Contexts header.");
+ }
+
+ /**
+ * Asserts that a given cache tag exists.
+ *
+ * This was part of the Simpletest base class, but doesn't exist in
+ * BrowserTestBase except in a legacy trait.
+ */
+ protected function assertCacheTag($expected_cache_tag) {
+ $tags = explode(' ', $this->getSession()->getResponseHeader('X-Drupal-Cache-Tags'));
+ $this->assertTrue(in_array($expected_cache_tag, $tags), "'" . $expected_cache_tag . "' is present in the X-Drupal-Cache-Tags header.");
+ }
+
+}
diff --git a/core/modules/config_help/tests/src/Functional/HelpTopicTranslateTest.php b/core/modules/config_help/tests/src/Functional/HelpTopicTranslateTest.php
new file mode 100644
index 0000000..a810120
--- /dev/null
+++ b/core/modules/config_help/tests/src/Functional/HelpTopicTranslateTest.php
@@ -0,0 +1,152 @@
+adminUser = $this->createUser([
+ 'access administration pages',
+ 'administer help topics',
+ 'view help topics',
+ 'use text format help',
+ 'translate configuration',
+ 'administer languages',
+ 'administer site configuration',
+ ]);
+
+ // Add a language.
+ ConfigurableLanguage::createFromLangcode('es')->save();
+ }
+
+ /**
+ * Logs in users, tests help translation.
+ */
+ public function testHelpTranslation() {
+ $this->drupalLogin($this->adminUser);
+
+ // Verify that the help topics admin page has translate links.
+ $this->drupalGet('admin/config/development/help');
+ $session = $this->assertSession();
+ $session->linkExists('Translate');
+
+ // Translate a topic.
+ $es_body = 'This is the fake Spanish body';
+ $es_title = 'This is the fake Spanish title';
+ $this->drupalGet('admin/config/development/help/manage/help_test/translate');
+ $this->clickLink('Add');
+ $this->drupalPostForm(NULL, [
+ 'translation[config_names][config_help.topic.help_test][label]' => $es_title,
+ 'translation[config_names][config_help.topic.help_test][body][0][text][value]' => $es_body,
+ ], 'Save translation');
+
+ // Visit the page in English and verify.
+ $this->drupalGet('admin/help-topic/help_test');
+ $session = $this->assertSession();
+ $session->pageTextContains('ABC Help Test module');
+ $session->pageTextContains('This is a test.');
+ $session->pageTextNotContains($es_title);
+ $session->pageTextNotContains($es_body);
+
+ // Visit the page in Spanish and verify.
+ $this->drupalGet('es/admin/help-topic/help_test');
+ $session = $this->assertSession();
+ $session->pageTextNotContains('ABC Help Test module');
+ $session->pageTextNotContains('This is a test.');
+ $session->pageTextContains($es_title);
+ $session->pageTextContains($es_body);
+
+ // Add a new topic sourced in Spanish.
+ $second_en_title = 'Second Test';
+ $second_es_title = 'Segunda Prueba';
+ $second_en_body = 'Second body';
+ $second_es_body = 'Segunda cuerpo';
+
+ $this->drupalPostForm('admin/config/development/help/add', [
+ 'langcode' => 'es',
+ 'label' => $second_es_title,
+ 'id' => 'foo',
+ 'top_level' => TRUE,
+ 'body[0][text][value]' => $second_es_body,
+ ], 'Save');
+
+ // Translate it into English.
+ $this->drupalGet('admin/config/development/help/manage/foo/translate');
+ $this->clickLink('Add');
+ $this->drupalPostForm(NULL, [
+ 'translation[config_names][config_help.topic.foo][label]' => $second_en_title,
+ 'translation[config_names][config_help.topic.foo][body][0][text][value]' => $second_en_body,
+ ], 'Save translation');
+
+ // Visit the page in English and verify.
+ $this->drupalGet('admin/help-topic/foo');
+ $session = $this->assertSession();
+ $session->pageTextContains($second_en_title);
+ $session->pageTextContains($second_en_body);
+ $session->pageTextNotContains($second_es_title);
+ $session->pageTextNotContains($second_es_body);
+
+ // Visit the page in Spanish and verify.
+ $this->drupalGet('es/admin/help-topic/foo');
+ $session = $this->assertSession();
+ $session->pageTextNotContains($second_en_title);
+ $session->pageTextNotContains($second_en_body);
+ $session->pageTextContains($second_es_title);
+ $session->pageTextContains($second_es_body);
+
+ // Visit the help landing page and verify correct title links are shown
+ // in both languages.
+ $this->drupalGet('admin/help');
+ $session = $this->assertSession();
+ $session->linkExists('ABC Help Test module');
+ $session->linkNotExists($es_title);
+ $session->linkExists($second_en_title);
+ $session->linkNotExists($second_es_title);
+
+ $this->drupalGet('es/admin/help');
+ $session = $this->assertSession();
+ $session->linkNotExists('ABC Help Test module');
+ $session->linkExists($es_title);
+ $session->linkNotExists($second_en_title);
+ $session->linkExists($second_es_title);
+ }
+
+}
diff --git a/core/modules/config_help/tests/src/Kernel/HelpTopicTokensTest.php b/core/modules/config_help/tests/src/Kernel/HelpTopicTokensTest.php
new file mode 100644
index 0000000..d8c32e4
--- /dev/null
+++ b/core/modules/config_help/tests/src/Kernel/HelpTopicTokensTest.php
@@ -0,0 +1,64 @@
+installSchema('system', ['router', 'sequences']);
+ $this->installConfig(['config_help']);
+ \Drupal::service('router.builder')->rebuild();
+ }
+
+ /**
+ * Tests that help topic tokens work.
+ */
+ public function testHelpTopicTokens() {
+ // Verify a URL token for a help topic.
+ $bubbleable_metadata = new BubbleableMetadata();
+ $text = 'This should Link to help topic';
+ $replaced = \Drupal::token()->replace($text, [], [], $bubbleable_metadata);
+ $this->assertTrue(strpos($replaced, 'assertTrue(in_array('config:config_help.topic.config_help', $bubbleable_metadata->getCacheTags()), 'Cache tag for the linked topic was added');
+
+ $text = 'This should Not link to help topic';
+ $replaced = \Drupal::token()->replace($text);
+ $this->assertTrue(strpos($replaced, '[help_topic:url:nonexistent]') !== FALSE, 'Nonexistent help topic did not get replaced');
+ }
+
+ /**
+ * Tests that route tokens work.
+ */
+ public function testRouteTokens() {
+ $text = 'This should Link to admin';
+ $replaced = \Drupal::token()->replace($text);
+ $this->assertTrue(strpos($replaced, 'Not link to admin';
+ $replaced = \Drupal::token()->replace($text);
+ $this->assertTrue(strpos($replaced, '[route:url:system.nonexistent]') !== FALSE, 'Nonexistent route was not replaced');
+ }
+
+}
diff --git a/core/modules/config_help/tests/src/Kernel/TextSectionRenderingTest.php b/core/modules/config_help/tests/src/Kernel/TextSectionRenderingTest.php
new file mode 100644
index 0000000..239067c
--- /dev/null
+++ b/core/modules/config_help/tests/src/Kernel/TextSectionRenderingTest.php
@@ -0,0 +1,221 @@
+ 'text_sections',
+ '#sections' => [
+ [
+ '#section_type' => 'heading',
+ '#text' => ['#markup' => 'First H2 header'],
+ ],
+ [
+ '#section_type' => 'heading',
+ '#text' => 'Second H2 header',
+ ],
+ [
+ '#section_type' => 'subheading',
+ '#text' => ['#markup' => 'First H3 sub-header'],
+ ],
+ [
+ '#section_type' => 'subheading',
+ '#text' => 'Second H3 sub-header',
+ ],
+ [
+ '#section_type' => 'paragraph',
+ '#text' => 'First paragraph',
+ ],
+ [
+ '#section_type' => 'paragraph',
+ '#text' => ['#markup' => 'Second paragraph'],
+ ],
+ [
+ '#section_type' => 'code',
+ '#text' => 'Some code',
+ ],
+ [
+ '#section_type' => 'code',
+ '#text' => ['#markup' => 'More code'],
+ ],
+ [
+ '#section_type' => 'bullet',
+ '#text' => ['#markup' => 'First list, first bullet'],
+ ],
+ [
+ '#section_type' => 'bullet',
+ '#text' => 'First list, second bullet',
+ ],
+ [
+ '#section_type' => 'numbered',
+ '#text' => ['#markup' => 'Numbered list, first item'],
+ ],
+ [
+ '#section_type' => 'numbered',
+ '#text' => 'Numbered list, second item',
+ ],
+ [
+ '#section_type' => 'description_name',
+ '#text' => 'DL list, first name',
+ ],
+ [
+ '#section_type' => 'description_value',
+ '#text' => 'DL list, first value',
+ ],
+ [
+ '#section_type' => 'description_name',
+ '#text' => ['#markup' => 'DL list, second name'],
+ ],
+ [
+ '#section_type' => 'description_value',
+ '#text' => ['#markup' => 'DL list, second value'],
+ ],
+ ],
+ ];
+
+ // Pre-render the element into a more basic render array.
+ $result = TextSections::preRenderSections($element);
+
+ // Compare to the expected result, but only the text_sections part.
+ $expected = [
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#markup' => 'First H2 header',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#markup' => 'Second H2 header',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#markup' => 'First H3 sub-header',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#markup' => 'Second H3 sub-header',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#markup' => 'First paragraph',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#markup' => 'Second paragraph',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#markup' => 'Some code',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#markup' => 'More code',
+ ],
+ [
+ '#theme' => 'item_list',
+ '#list_type' => 'ul',
+ '#items' => [
+ ['#markup' => 'First list, first bullet'],
+ ['#markup' => 'First list, second bullet'],
+ ],
+ ],
+ [
+ '#theme' => 'item_list',
+ '#list_type' => 'ol',
+ '#items' => [
+ ['#markup' => 'Numbered list, first item'],
+ ['#markup' => 'Numbered list, second item'],
+ ],
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '
',
+ 'list' => [
+ [
+ '#prefix' => '',
+ '#suffix' => '',
+ '#markup' => 'DL list, first name',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '',
+ '#markup' => 'DL list, first value',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '',
+ '#markup' => 'DL list, second name',
+ ],
+ [
+ '#prefix' => '',
+ '#suffix' => '',
+ '#markup' => 'DL list, second value',
+ ],
+ ],
+ ],
+ ];
+
+ $this->assertTrue($result['text_sections'] == $expected, 'Pre-processing behaved as expected');
+ }
+
+ /**
+ * Tests the options list for text sections.
+ */
+ public function testOptionsList() {
+ /** @var \Drupal\config_help\TextSectionManager $manager */
+ $manager = \Drupal::service('plugin.manager.text_section');
+ $options = $manager->getOptionsList();
+
+ // The sorting of the list should be by weight first, then alphabetically
+ // by label.
+ $expected = [
+ // Weight is -100.
+ 'paragraph' => 'Paragraph',
+ // Weight is -20.
+ 'heading' => 'Heading',
+ // Weight is -19.
+ 'subheading' => 'Sub-heading',
+ // Weight is -10.
+ 'bullet' => 'Bullet list item',
+ 'numbered' => 'Numbered list item',
+ // Weight is 10.
+ 'description_name' => 'Description list name item',
+ // Weight is 11.
+ 'description_value' => 'Description list value item',
+ // Weight is 100.
+ 'code' => 'Code',
+ ];
+
+ $this->assertTrue($options == $expected, 'Options list was as expected');
+ }
+
+}