diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt index 0867a84..d69df14 100644 --- a/core/MAINTAINERS.txt +++ b/core/MAINTAINERS.txt @@ -123,6 +123,10 @@ Comment - Lee Rowlands 'larowlan' https://www.drupal.org/u/larowlan - Andrey Postnikov 'andypost' https://www.drupal.org/u/andypost +Configurable Help +- Amber Matz 'Amber Himes Matz' https://www.drupal.org/u/amber-himes-matz +- Andrey Postnikov 'andypost' https://www.drupal.org/u/andypost + Configuration API - Alex Pott 'alexpott' https://www.drupal.org/u/alexpott - Matthew Tift 'mtift' https://www.drupal.org/u/mtift diff --git a/core/composer.json b/core/composer.json index c4bb384..17fdfd3 100644 --- a/core/composer.json +++ b/core/composer.json @@ -67,6 +67,7 @@ "drupal/color": "self.version", "drupal/comment": "self.version", "drupal/config": "self.version", + "drupal/config_help": "self.version", "drupal/config_translation": "self.version", "drupal/contact": "self.version", "drupal/content_moderation": "self.version", diff --git a/core/modules/config_help/config/install/config_help.topic.config_basic.yml b/core/modules/config_help/config/install/config_help.topic.config_basic.yml new file mode 100644 index 0000000..b316530 --- /dev/null +++ b/core/modules/config_help/config/install/config_help.topic.config_basic.yml @@ -0,0 +1,44 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.help + enforced: + module: { } + theme: { } +id: config_basic +label: 'Changing basic site settings' +top_level: true +locked: true +related: { } +list_on: { } +body: + - + text: 'The settings for your site are configured on various administrative pages, as follows:' + prefix_tags: '
' + suffix_tags: '
' + - + text: 'Site name, slogan, and email address' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Responding to software errors' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Viewing the site log' + prefix_tags: '' + suffix_tags: '
' +body_format: help diff --git a/core/modules/config_help/config/install/config_help.topic.config_help.yml b/core/modules/config_help/config/install/config_help.topic.config_help.yml new file mode 100644 index 0000000..1ad630d --- /dev/null +++ b/core/modules/config_help/config/install/config_help.topic.config_help.yml @@ -0,0 +1,42 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.help + enforced: + module: { } + theme: { } +id: config_help +label: 'Building a help system' +top_level: true +locked: true +related: + - config_help_form + - config_help_writing +list_on: { } +body: + - + text: 'Follow these steps to build a help system for different types/roles of users on your system to use:' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Plan your help system: make a list of the topics each type/role of user would benefit from.' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Top-level topic' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Choose short titles. If the topic describes a task, start with a verb in -ing form, like "Building a help system".' + prefix_tags: '' + suffix_tags: '
' +body_format: help diff --git a/core/modules/config_help/config/install/config_help.topic.menu_overview.yml b/core/modules/config_help/config/install/config_help.topic.menu_overview.yml new file mode 100644 index 0000000..25c3a90 --- /dev/null +++ b/core/modules/config_help/config/install/config_help.topic.menu_overview.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.help + enforced: + module: { } + theme: { } +id: menu_overview +label: 'Defining navigation and URLs' +top_level: true +locked: true +related: { } +list_on: { } +body: + - + text: 'The related topics listed here describe how to set up various aspects of site navigation and URLs.' + prefix_tags: '' + suffix_tags: '
' +body_format: help diff --git a/core/modules/config_help/config/install/config_help.topic.security.yml b/core/modules/config_help/config/install/config_help.topic.security.yml new file mode 100644 index 0000000..6f83731 --- /dev/null +++ b/core/modules/config_help/config/install/config_help.topic.security.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.help + enforced: + module: { } + theme: { } +id: security +label: 'Making your site secure' +top_level: true +locked: true +related: { } +list_on: { } +body: + - + text: 'The topics listed here will help you make and keep your site secure.' + prefix_tags: '' + suffix_tags: '
' +body_format: help diff --git a/core/modules/config_help/config/install/config_help.topic.security_account_settings.yml b/core/modules/config_help/config/install/config_help.topic.security_account_settings.yml new file mode 100644 index 0000000..df458c3 --- /dev/null +++ b/core/modules/config_help/config/install/config_help.topic.security_account_settings.yml @@ -0,0 +1,39 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.help + enforced: + module: { } + theme: { } +id: security_account_settings +label: 'Defining how user accounts are created' +top_level: false +locked: true +related: + - security +list_on: + - config_basic + - security +body: + - + text: 'On the Account settings page, which you can reach from the Manage administrative menu, by navigating to Configuration > People > Account settings (requires the Administer account settings permission), you can configure several settings related to how user accounts are created:' + prefix_tags: '' + suffix_tags: '
' + - + text: 'You can make it possible for new users to register themselves, with or without administrator approval. Or, you can make it so only administrators with Administer users permission can register new users.' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Disabling drag-and-drop functionality' + prefix_tags: '' + suffix_tags: '
' +body_format: help diff --git a/core/modules/config_help/config/install/config_help.topic.ui_contextual.yml b/core/modules/config_help/config/install/config_help.topic.ui_contextual.yml new file mode 100644 index 0000000..f159770 --- /dev/null +++ b/core/modules/config_help/config/install/config_help.topic.ui_contextual.yml @@ -0,0 +1,46 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.help + enforced: + module: { } + theme: { } +id: ui_contextual +label: 'Contextual links' +top_level: false +locked: true +related: + - ui_components +list_on: + - ui_components +body: + - + text: 'What are contextual links?' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Displaying and using contextual links' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Hovering your mouse over an area on the page will temporarily make the contextual links button visible, if there is one for that area of the page. Also, in most themes, the page area that the contextual links pertain to will be outlined while you are hovering.' + prefix_tags: '' + suffix_tags: '
' +body_format: help diff --git a/core/modules/config_help/config/install/config_help.topic.ui_shortcuts.yml b/core/modules/config_help/config/install/config_help.topic.ui_shortcuts.yml new file mode 100644 index 0000000..a7e55a1 --- /dev/null +++ b/core/modules/config_help/config/install/config_help.topic.ui_shortcuts.yml @@ -0,0 +1,42 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.help + enforced: + module: { } + theme: { } +id: ui_shortcuts +label: Shortcuts +top_level: false +locked: true +related: + - ui_components +list_on: + - ui_components +body: + - + text: 'What are shortcuts?' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Creating and deleting shortcuts' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Viewing and using shortcuts' + prefix_tags: '' + suffix_tags: '
' +body_format: help diff --git a/core/modules/config_help/config/install/config_help.topic.ui_tours.yml b/core/modules/config_help/config/install/config_help.topic.ui_tours.yml new file mode 100644 index 0000000..b218460 --- /dev/null +++ b/core/modules/config_help/config/install/config_help.topic.ui_tours.yml @@ -0,0 +1,34 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.help + enforced: + module: { } + theme: { } +id: ui_tours +label: Tours +top_level: false +locked: true +related: + - ui_components +list_on: + - ui_components +body: + - + text: 'What are tours?' + prefix_tags: '' + suffix_tags: '
' + - + text: 'Viewing tours' + prefix_tags: '' + suffix_tags: '
' +body_format: help diff --git a/core/modules/config_help/config/install/filter.format.help.yml b/core/modules/config_help/config/install/filter.format.help.yml new file mode 100644 index 0000000..274682a --- /dev/null +++ b/core/modules/config_help/config/install/filter.format.help.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + enforced: + module: + - config_help +name: Help +format: help +weight: 0 +filters: + filter_html: + id: filter_html + provider: filter + status: true + weight: -10 + settings: + allowed_html: '
'
+ filter_html_help: true
+ filter_html_nofollow: false
diff --git a/core/modules/config_help/config/optional/editor.editor.help.yml b/core/modules/config_help/config/optional/editor.editor.help.yml
new file mode 100644
index 0000000..66c2f58
--- /dev/null
+++ b/core/modules/config_help/config/optional/editor.editor.help.yml
@@ -0,0 +1,52 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - filter.format.help
+ module:
+ - ckeditor
+format: help
+editor: ckeditor
+settings:
+ toolbar:
+ rows:
+ -
+ -
+ name: Formatting
+ items:
+ - Bold
+ - Italic
+ -
+ name: Links
+ items:
+ - DrupalLink
+ - DrupalUnlink
+ -
+ name: Lists
+ items:
+ - BulletedList
+ - NumberedList
+ - Indent
+ - Outdent
+ - Table
+ -
+ name: Media
+ items:
+ - DrupalImage
+ -
+ name: Tools
+ items:
+ - Source
+ plugins:
+ stylescombo:
+ styles: ''
+ language:
+ language_list: un
+image_upload:
+ status: false
+ scheme: public
+ directory: inline-images
+ max_size: ''
+ max_dimensions:
+ width: null
+ height: null
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..b6a6364
--- /dev/null
+++ b/core/modules/config_help/config/schema/config_help.schema.yml
@@ -0,0 +1,48 @@
+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'
+ form_element_class: '\Drupal\config_help\FormElement\HelpTopicBody'
+ sequence:
+ type: config_help_text
+ body_format:
+ type: string
+ label: 'Body format'
+
+config_help_text:
+ type: mapping
+ mapping:
+ prefix_tags:
+ type: string
+ label: 'Prefix tags'
+ text:
+ type: text
+ label: 'Text'
+ suffix_tags:
+ type: string
+ label: 'Suffix tags'
diff --git a/core/modules/config_help/config_help.info.yml b/core/modules/config_help/config_help.info.yml
new file mode 100644
index 0000000..fbbffad
--- /dev/null
+++ b/core/modules/config_help/config_help.info.yml
@@ -0,0 +1,10 @@
+name: Configurable Help
+type: module
+description: 'Provides a configurable help system'
+core: 8.x
+package: Core (Experimental)
+configure: entity.help_topic.collection
+version: VERSION
+dependencies:
+ - drupal:help
+ - drupal:filter
diff --git a/core/modules/config_help/config_help.links.action.yml b/core/modules/config_help/config_help.links.action.yml
new file mode 100644
index 0000000..b4432d3
--- /dev/null
+++ b/core/modules/config_help/config_help.links.action.yml
@@ -0,0 +1,5 @@
+entity.help_topic.add_form:
+ route_name: entity.help_topic.add_form
+ title: 'Add new help topic'
+ appears_on:
+ - entity.help_topic.collection
diff --git a/core/modules/config_help/config_help.links.menu.yml b/core/modules/config_help/config_help.links.menu.yml
new file mode 100644
index 0000000..f62c4fb
--- /dev/null
+++ b/core/modules/config_help/config_help.links.menu.yml
@@ -0,0 +1,5 @@
+entity.help_topic.collection:
+ title: Help topics
+ description: Add, delete, and edit help topics.
+ route_name: entity.help_topic.collection
+ parent: system.admin_config_development
diff --git a/core/modules/config_help/config_help.links.task.yml b/core/modules/config_help/config_help.links.task.yml
new file mode 100644
index 0000000..4dafd75
--- /dev/null
+++ b/core/modules/config_help/config_help.links.task.yml
@@ -0,0 +1,16 @@
+entity.help_topic.canonical:
+ title: View
+ route_name: entity.help_topic.canonical
+ base_route: entity.help_topic.edit_form
+ weight: -10
+
+entity.help_topic.edit_form:
+ title: Edit
+ route_name: entity.help_topic.edit_form
+ base_route: entity.help_topic.edit_form
+
+entity.help_topic.delete_form:
+ route_name: entity.help_topic.delete_form
+ base_route: entity.help_topic.edit_form
+ title: Delete
+ weight: 10
diff --git a/core/modules/config_help/config_help.module b/core/modules/config_help/config_help.module
new file mode 100644
index 0000000..b7e2319
--- /dev/null
+++ b/core/modules/config_help/config_help.module
@@ -0,0 +1,48 @@
+' . t('About') . '';
+ $output .= '' . 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']) . '
';
+ $output .= '' . t('Uses') . '
';
+ $output .= '';
+ $output .= '- ' . t('Configuring help topics') . '
';
+ $output .= '- ' . 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(),
+ ]) . '
';
+ $output .= '- ' . t('Viewing configurable help topics') . '
';
+ $output .= '- ' . t('The top-level configured help topics are listed on the main Help page.', [':help_page' => Url::fromRoute('help.main')->toString()]) . '
';
+ $output .= '- ' . t('Updating help topics') . '
';
+ $output .= '- ' . 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']) . '
';
+ $output .= '
';
+ return ['#markup' => $output];
+ }
+}
+
+/**
+ * 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..07ec438
--- /dev/null
+++ b/core/modules/config_help/config_help.services.yml
@@ -0,0 +1,6 @@
+services:
+ config_help.breadcrumb:
+ class: Drupal\config_help\HelpBreadcrumbBuilder
+ arguments: ['@string_translation']
+ tags:
+ - { name: breadcrumb_builder, priority: 900 }
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/Controller/AutocompleteController.php b/core/modules/config_help/src/Controller/AutocompleteController.php
new file mode 100644
index 0000000..3990c20
--- /dev/null
+++ b/core/modules/config_help/src/Controller/AutocompleteController.php
@@ -0,0 +1,214 @@
+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/Entity/HelpTopic.php b/core/modules/config_help/src/Entity/HelpTopic.php
new file mode 100644
index 0000000..478017a
--- /dev/null
+++ b/core/modules/config_help/src/Entity/HelpTopic.php
@@ -0,0 +1,474 @@
+get('body') as $chunk) {
+ $body_text .= $chunk['prefix_tags'] . $chunk['text'] . $chunk['suffix_tags'];
+ }
+ return $body_text;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setBody($body) {
+ // Chunk the passed-in string and save it as an array.
+ $dom = Html::load($body);
+ if (!$dom) {
+ throw new ConfigValueException($this->t('Body HTML is malformed'));
+ }
+ $chunks = [];
+ $body_node = $dom->getElementsByTagName('body')->item(0);
+ if ($body_node) {
+ $chunks = $this->chunkDomNode($dom, $body_node);
+ }
+ return $this->set('body', $chunks);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getBodyFormat() {
+ return $this->get('body_format');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setBodyFormat($format) {
+ return $this->set('body_format', $format);
+ }
+
+ /**
+ * {@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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function calculateDependencies() {
+ parent::calculateDependencies();
+
+ if ($this->body_format) {
+ $format = $this->entityManager()->getStorage('filter_format')->load($this->body_format);
+ if ($format) {
+ $this->addDependency('config', $format->getConfigDependencyName());
+ }
+ }
+
+ 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);
+ }
+ }
+ }
+
+ /**
+ * Splits a DOM node recursively into chunks.
+ *
+ * @param \DOMDocument $dom
+ * The DOM document this came from.
+ * @param \DOMNode $node
+ * DOM node to split into chunks.
+ * @param string $prefix
+ * (optional) Prefix tags to put on first chunk.
+ * @param string $suffix
+ * (optional) Suffix tags to put on last chunk.
+ *
+ * @return array
+ * Array of chunks from $node. Each chunk is an array with elements:
+ * - text: Text content of the chunk, which may contain HTML.
+ * - prefix_tags: HTML tags that go before the text content.
+ * - suffix_tags: HTML tags that go after the text content.
+ * The intent is that if you concatenate all the chunks' prefix_tags,
+ * text, and suffix_tags, you will end up with the original HTML.
+ */
+ protected function chunkDomNode(\DOMDocument $dom, \DOMNode $node, $prefix = '', $suffix = '') {
+
+ // These HTML tags generate an automatic chunk.
+ $chunk_tags = [
+ 'p',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'li',
+ 'dt',
+ 'dd',
+ 'code',
+ 'pre',
+ 'blockquote',
+ 'td',
+ 'th',
+ 'caption',
+ ];
+
+ $chunks = [];
+ foreach ($node->childNodes as $node) {
+ switch ($node->nodeType) {
+ case XML_ELEMENT_NODE:
+ if (!$node->hasChildNodes()) {
+ // It's an empty element, so just add it to the prefix for the
+ // next chunk.
+ $prefix .= $dom->saveXML($node);
+ }
+ else {
+ $open_tag = '<' . $node->tagName;
+ if ($node->hasAttributes()) {
+ foreach ($node->attributes as $attr) {
+ $open_tag .= $dom->saveXML($attr);
+ }
+ }
+ $open_tag .= '>';
+ $close_tag = '' . $node->tagName . '>';
+
+ if (in_array($node->tagName, $chunk_tags)) {
+ // Don't go deeper, just save everything this node contains
+ // as one chunk.
+ $text = '';
+ foreach ($node->childNodes as $inner) {
+ $text .= $dom->saveXML($inner);
+ }
+ $chunks[] = [
+ 'text' => $text,
+ 'prefix_tags' => $prefix . $open_tag,
+ 'suffix_tags' => $close_tag,
+ ];
+ $prefix = '';
+ }
+ else {
+ // Recursively generate chunks from this node's children.
+ $new_chunks = $this->chunkDomNode($dom, $node, $prefix . $open_tag, $close_tag);
+ $chunks = array_merge($chunks, $new_chunks);
+ $prefix = '';
+ }
+ }
+ break;
+
+ case XML_TEXT_NODE:
+ // Add text nodes as their own chunks.
+ $chunks[] = [
+ 'text' => $dom->saveXML($node),
+ 'prefix_tags' => $prefix,
+ 'suffix_tags' => '',
+ ];
+ $prefix = '';
+ break;
+
+ default:
+ // For nodes that are anything except elements or text, just add
+ // them to the prefix we are working on. These are things like HTML
+ // comments, for example.
+ $prefix .= $dom->saveXML($node);
+ break;
+ }
+ }
+
+ // If we have prefix left over, add it as a chunk.
+ if ($prefix) {
+ $chunks[] = [
+ 'text' => '',
+ 'prefix_tags' => $prefix,
+ 'suffix_tags' => '',
+ ];
+ }
+
+ // If we have a suffix left over, add it to the last chunk.
+ if ($suffix) {
+ if (!$chunks) {
+ $chunks[] = [
+ 'text' => '',
+ 'prefix_tags' => '',
+ 'suffix_tags' => '',
+ ];
+ }
+ $chunks[count($chunks) - 1]['suffix_tags'] .= $suffix;
+ }
+
+ return $chunks;
+ }
+
+}
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..9d1ec48
--- /dev/null
+++ b/core/modules/config_help/src/Form/HelpLockForm.php
@@ -0,0 +1,61 @@
+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();
+
+ $link = $this->entity->toLink(NULL, 'canonical')->toString();
+ $ops_link = $this->entity->toLink($this->t('View'), 'canonical')->toString();
+ $args = ['@link' => $link];
+ $message = $this->t('The help topic @link has been locked.', $args);
+ drupal_set_message($message);
+ $this->getLogger('config_help')->notice('The help topic @link has been locked.', $args + [
+ 'link' => $ops_link,
+ ]);
+
+ $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..dd09444
--- /dev/null
+++ b/core/modules/config_help/src/Form/HelpTopicForm.php
@@ -0,0 +1,297 @@
+helpStorage = $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')
+ );
+ }
+
+ /**
+ * 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,
+ ];
+
+ $form['body'] = [
+ '#type' => 'text_format',
+ '#title' => $this->t('Body'),
+ '#default_value' => $entity->getBody(),
+ '#format' => $entity->getBodyFormat(),
+ '#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.'),
+ ];
+ // The filter module will deny access if the format is set to '', so unset
+ // to instead use the first format that the user can access as the default.
+ if (!$form['body']['#format']) {
+ unset($form['body']['#format']);
+ }
+
+ $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);
+ }
+
+ /**
+ * 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) {
+ $body = $form_state->getValue('body');
+ $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($body['value'])
+ ->setBodyFormat($body['format']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ // Make sure that the HTML in the body can at least be loaded/parsed.
+ if (!Html::load($form_state->getValue('body')['value'])) {
+ $form_state->setErrorByName('body', $this->t('Body HTML is malformed'));
+ }
+
+ // 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) {
+ $status = $this->entity->save();
+
+ // Redirect to the View page, unless the user cannot view the page.
+ // Query parameter 'destination' will override this.
+ if ($this->entity->access('view')) {
+ $form_state->setRedirectUrl($this->entity->toUrl('canonical'));
+ }
+ else {
+ $form_state->setRedirect('entity.help_topic.collection');
+ }
+
+ $link = $this->entity->toLink(NULL, 'canonical')->toString();
+ $ops_link = $this->entity->toLink($this->t('View'), 'canonical')->toString();
+ $args = ['@link' => $link];
+ if ($status == SAVED_UPDATED) {
+ $message = $this->t('The help topic @link has been updated.', $args);
+ drupal_set_message($message);
+ $this->getLogger('config_help')->notice('The help topic @link has been updated.', $args + [
+ 'link' => $ops_link,
+ ]);
+ }
+ else {
+ $message = $this->t('The help topic @link has been added.', $args);
+ drupal_set_message($message);
+ $this->getLogger('config_help')->notice('The help topic @link has been added.', $args + [
+ 'link' => $ops_link,
+ ]);
+ }
+ }
+
+}
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..208e1cd
--- /dev/null
+++ b/core/modules/config_help/src/Form/HelpUnlockForm.php
@@ -0,0 +1,61 @@
+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();
+
+ $link = $this->entity->toLink(NULL, 'canonical')->toString();
+ $ops_link = $this->entity->toLink($this->t('View'), 'canonical')->toString();
+ $args = ['@link' => $link];
+ $message = $this->t('The help topic @link has been unlocked.', $args);
+ drupal_set_message($message);
+ $this->getLogger('config_help')->notice('The help topic @link has been unlocked.', $args + [
+ 'link' => $ops_link,
+ ]);
+
+ $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/FormElement/HelpTopicBody.php b/core/modules/config_help/src/FormElement/HelpTopicBody.php
new file mode 100644
index 0000000..7f0ec6f
--- /dev/null
+++ b/core/modules/config_help/src/FormElement/HelpTopicBody.php
@@ -0,0 +1,67 @@
+joinBody($source_config)));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getTranslationElement(LanguageInterface $translation_language, $source_config, $translation_config) {
+ return parent::getTranslationElement($translation_language, $this->joinBody($source_config), $this->joinBody($translation_config));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setConfig(Config $base_config, LanguageConfigOverride $config_translation, $config_values, $base_key = NULL) {
+ return parent::setConfig($base_config, $config_translation, $this->splitBody($config_values), $base_key);
+ }
+
+ /**
+ * Makes raw body array into editable body text.
+ *
+ * @param array $raw_body
+ * Raw chunked body array.
+ *
+ * @return string
+ * Editable string with body chunks joined.
+ */
+ protected function joinBody($raw_body) {
+ $entity = HelpTopic::create(['body' => $raw_body]);
+ return $entity->getBody();
+ }
+
+ /**
+ * Makes editable body text into a raw body array.
+ *
+ * @param string $edited_body
+ * Edited string with body chunks joined.
+ *
+ * @return array
+ * Raw chunked body array.
+ */
+ protected function splitBody($edited_body) {
+ $entity = HelpTopic::create();
+ $entity->setBody($edited_body);
+ return $entity->get('body');
+ }
+
+}
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..93ff7f5
--- /dev/null
+++ b/core/modules/config_help/src/HelpTopicInterface.php
@@ -0,0 +1,141 @@
+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..c7a928a
--- /dev/null
+++ b/core/modules/config_help/src/HelpViewBuilder.php
@@ -0,0 +1,132 @@
+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, with token replacement.
+ $bubbleable_metadata = new BubbleableMetadata();
+ $build['#body'] = [
+ '#type' => 'processed_text',
+ '#text' => $this->token->replace($help_topic->getBody(), [], [], $bubbleable_metadata),
+ '#format' => $help_topic->getBodyFormat(),
+ ];
+ $bubbleable_metadata->applyTo($build['#body']);
+
+ // 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;
+ }
+
+}
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/templates/help-topic.html.twig b/core/modules/config_help/templates/help-topic.html.twig
new file mode 100644
index 0000000..49c8f25
--- /dev/null
+++ b/core/modules/config_help/templates/help-topic.html.twig
@@ -0,0 +1,16 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display a help topic.
+ *
+ * Available variables:
+ * - body: The body of the topic.
+ * - related: List of related topic links.
+ *
+ * @ingroup themeable
+ */
+#}
+
+ {{ 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..2dddc27
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test.yml
@@ -0,0 +1,23 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - filter.format.help
+ 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:
+ -
+ text: '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.'
+ prefix_tags: ''
+ suffix_tags: '
'
+body_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..f2ca0cf
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_additional.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - filter.format.help
+ enforced:
+ module:
+ - config_help_test
+ theme: { }
+id: help_test_additional
+label: 'Additional topic'
+top_level: false
+locked: false
+related: { }
+list_on:
+ - help_test
+body:
+ -
+ text: 'This topic should get listed automatically on the Help test topic.'
+ prefix_tags: ''
+ suffix_tags: '
'
+body_format: help
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..1d639fc
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_linked.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - filter.format.help
+ enforced:
+ module:
+ - config_help_test
+ theme: { }
+id: help_test_linked
+label: 'Linked topic'
+top_level: false
+locked: false
+related: { }
+list_on: { }
+body:
+ -
+ text: 'This topic is not supposed to be top-level.'
+ prefix_tags: ''
+ suffix_tags: '
'
+body_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..c96d5c2
--- /dev/null
+++ b/core/modules/config_help/tests/modules/config_help_test/config/optional/config_help.topic.help_test_locked.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - filter.format.help
+ enforced:
+ module:
+ - config_help_test
+ theme: { }
+id: help_test_locked
+label: 'Locked topic'
+top_level: false
+locked: true
+related: { }
+list_on: { }
+body:
+ -
+ text: 'This topic is supposed to be locked to editing.'
+ prefix_tags: ''
+ suffix_tags: '
'
+body_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',
+ 'access site reports',
+ ]);
+ $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;
+ }
+
+ $dblogs = [];
+
+ // 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');
+ $title = 'Locked topic';
+ $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($title);
+ $this->drupalPostForm(NULL, [], 'Unlock');
+ $session = $this->assertSession();
+ $session->pageTextContains("The help topic $title has been unlocked.");
+ $dblogs[] = "The help topic $title has been unlocked.";
+ $dblogs[] = 'View';
+
+ // We should be back on the topic list page. Verify this, and verify that
+ // if we edit a topic from here, we get back to the topic list.
+ $session->pageTextContains('Help topics');
+ $this->clickLink('Edit');
+ $this->drupalPostForm(NULL, [
+ 'modules' => 'color',
+ ], 'Save');
+ $session = $this->assertSession();
+ // Not sure which topic we would be editing.
+ $session->pageTextContains("The help topic");
+ $session->pageTextContains("has been updated.");
+ $session->pageTextContains('Help topics');
+
+ // 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("The help topic $title has been locked.");
+ $dblogs[] = "The help topic $title has been locked.";
+
+ // 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);
+
+ $this->drupalGet('admin/reports/dblog');
+ $session = $this->assertSession();
+ foreach ($dblogs as $message) {
+ $session->linkExists($message);
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ $dblogs = [];
+
+ // 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[value]' => $body,
+ ], 'Save');
+ $session = $this->assertSession();
+ $session->pageTextContains("The help topic $title has been added.");
+ $dblogs[] = "The help topic $title has been added.";
+ $dblogs[] = 'View';
+
+ // After creating, we should be on the View page.
+ $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');
+ // After editing, we should be back on the View page.
+ $session = $this->assertSession();
+ $session->pageTextContains("The help topic $new_title has been updated.");
+ $dblogs[] = "The help topic $new_title has been updated.";
+ $session->linkExists($new_title);
+ $session->pageTextContains($body);
+
+ // 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 $new_title has been deleted");
+ $dblogs[] = "The help topic $new_title has been deleted";
+
+ // 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[value]' => $body,
+ 'related' => 'invalid.text',
+ ], 'Save');
+ $session = $this->assertSession();
+ $session->pageTextNotContains('has been 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('has been 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);
+
+ $this->drupalGet('admin/reports/dblog');
+ $session = $this->assertSession();
+ foreach ($dblogs as $message) {
+ $session->linkExists($message);
+ }
+ }
+
+ /**
+ * 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..9a73e8d
--- /dev/null
+++ b/core/modules/config_help/tests/src/Functional/HelpTopicTest.php
@@ -0,0 +1,347 @@
+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' => [
+ [
+ 'text' => 'Greetings',
+ 'prefix_tags' => '',
+ 'suffix_tags' => '
',
+ ],
+ [
+ 'text' => 'Hello, world!',
+ 'prefix_tags' => '',
+ 'suffix_tags' => '
',
+ ],
+ ],
+ 'body_format' => 'help',
+ '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);
+ // Normally on config entity save, dependencies would be calculated.
+ // Force it manually.
+ $foo->calculateDependencies();
+
+ // 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 and body, which are checked separately.
+ 'id' => 'id',
+ 'label' => 'label',
+ 'getBody' => FALSE,
+ 'getBodyFormat' => 'body_format',
+ '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');
+ }
+
+ // Check that the getEnforcedDepenencies() method works correctly. The
+ // getBody() and setBody() methods are checked in a different test method.
+ $this->assertEqual($foo->getEnforcedDependencies(), $dependencies, 'Data for dependencies is the same as method getEnforcedDependencies');
+
+ // Verify that the body format dependency is there.
+ $after_dependencies = $bar->getDependencies();
+ $this->assertEqual($after_dependencies['config'][0], 'filter.format.help');
+ }
+
+ /**
+ * Tests the help topic body get and set functions.
+ */
+ public function testTopicBodyGetSet() {
+ $topic = HelpTopic::create();
+
+ $body = '' .
+ 'A paragraph with attributes
' .
+ 'A heading
' .
+ 'A sub-heading
' .
+ '- Bullet 1
- Bullet 2
' .
+ '- Number 1
- Number 2
' .
+ 'Col 1 Col 2 ' .
+ 'Data 1 Data 2
' .
+ '- Item 1
- Definition 1
- Item 2
- Definition 2
';
+
+ // Make sure after set/get, the body is unchanged.
+ $topic->setBody($body);
+ $body_out = $topic->getBody($body);
+ $this->assertEqual($body, $body_out);
+
+ // Make sure that if we strip out tags, all remaining text is in the 'text'
+ // parts of the chunked body array.
+ $body_plain = strip_tags($body);
+ $body_chunked = $topic->toArray()['body'];
+ $chunked_text = '';
+ foreach ($body_chunked as $item) {
+ $chunked_text .= $item['text'];
+ }
+ $this->assertEqual($body_plain, $chunked_text);
+ }
+
+ /**
+ * 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:filter.format.help',
+ ],
+ ],
+ 'config_help' => [
+ 'name' => 'Building a help system',
+ 'cache_tags' => [
+ 'config:config_help.topic.config_help',
+ 'config:filter.format.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..c3f6429
--- /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]' => $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[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]' => $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');
+ }
+
+}