first commit

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

View File

@@ -0,0 +1,153 @@
/**
* @file
* Styling for tour module.
*/
/* Tab appearance. */
.toolbar .toolbar-bar .tour-toolbar-tab.toolbar-tab {
float: right; /* LTR */
}
[dir="rtl"] .toolbar .toolbar-bar .tour-toolbar-tab.toolbar-tab {
float: left;
}
/* Style the tour progress indicator. */
.tour-progress {
position: absolute;
right: 20px; /* LTR */
bottom: 20px;
}
[dir="rtl"] .tour-progress {
right: auto;
left: 20px;
}
/**
* The following are largely Shepherd's default styles, with a few modifications
* to facilitate a graceful transition from Joyride, the library used prior to
* Shepherd.
*/
.shepherd-footer {
display: flex;
justify-content: flex-start;
padding: 0 20px 20px;
}
.shepherd-footer .shepherd-button:last-child {
margin-right: 0;
}
.shepherd-cancel-icon {
position: absolute;
top: 20px;
right: 20px;
margin: 0;
padding: 0;
cursor: pointer;
border: none;
background: transparent;
line-height: 1em;
}
.shepherd-title {
margin: 0;
padding: 0;
}
.shepherd-header {
position: relative;
margin-bottom: 10px;
padding: 20px 50px 0 20px;
}
.shepherd-text {
padding: 0 20px;
}
.shepherd-text p {
margin: 0 0 1.4em;
}
.shepherd-element {
z-index: 110;
width: 300px;
background: #fff;
}
@media only screen and (max-width: 767px) {
.shepherd-element {
left: 2.5%;
width: 85%;
}
}
.shepherd-enabled.shepherd-element {
opacity: 1;
}
.shepherd-element[data-popper-reference-hidden]:not(.shepherd-centered) {
opacity: 0;
}
.shepherd-element,
.shepherd-element *,
.shepherd-element ::after,
.shepherd-element ::before {
box-sizing: border-box;
}
.shepherd-arrow,
.shepherd-arrow::before {
position: absolute;
width: 16px;
height: 16px;
}
.shepherd-arrow::before {
content: "";
transform: rotate(45deg);
background: #fff;
}
.shepherd-element[data-popper-placement^="top"] > .shepherd-arrow {
bottom: -8px;
}
.shepherd-element[data-popper-placement^="bottom"] > .shepherd-arrow {
top: -8px;
}
.shepherd-element[data-popper-placement^="left"] > .shepherd-arrow {
right: -8px;
}
.shepherd-element[data-popper-placement^="right"] > .shepherd-arrow {
left: -8px;
}
.shepherd-target-click-disabled.shepherd-enabled.shepherd-target,
.shepherd-target-click-disabled.shepherd-enabled.shepherd-target * {
pointer-events: none;
}
.shepherd-modal-overlay-container {
position: fixed;
z-index: 105;
top: 0;
left: 0;
overflow: hidden;
width: 100vw;
height: 0;
pointer-events: none;
opacity: 0;
fill-rule: evenodd;
}
.shepherd-modal-overlay-container.shepherd-modal-is-visible {
height: 100vh;
opacity: 0.5;
}
.shepherd-modal-overlay-container.shepherd-modal-is-visible path {
pointer-events: all;
}

View File

@@ -0,0 +1,16 @@
---
label: 'Taking tours of administrative pages'
related:
- core.ui_components
---
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Take a tour of an administrative page.{% endtrans %}</p>
<h2>{% trans %}What are tours?{% endtrans %}</h2>
<p>{% trans %}The core Tour module provides users with <em>tours</em>, which are guided tours of the administrative interface. Each tour starts on a particular administrative page, and consists of one or more <em>tips</em> that highlight elements of the page, guide you through a workflow, or explain key concepts. Users need <em>Access tour</em> permission to view tours, and JavaScript must be enabled in their browsers.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}Make sure that the core Tour module is installed, and that you have a role with the <em>Access tour</em> permission. Also, make sure that a toolbar module is installed (either the core Toolbar module or a contributed module replacement).{% endtrans %}</li>
<li>{% trans %}Visit an administrative page that has a tour, such as the edit view page provided by the core Views UI module.{% endtrans %}</li>
<li>{% trans %}Click the <em>Tour</em> button at the right end of the toolbar (left end for right-to-left languages). The first tip of the tour should appear.{% endtrans %}</li>
<li>{% trans %}Click the <em>Next</em> button to advance to the next tip, and <em>End tour</em> at the end to close the tour.{% endtrans %}</li>
</ol>

414
core/modules/tour/js/tour.js Executable file
View File

@@ -0,0 +1,414 @@
/**
* @file
* Attaches behaviors for the Tour module's toolbar tab.
*/
(($, Backbone, Drupal, settings, document, Shepherd) => {
const queryString = decodeURI(window.location.search);
/**
* Attaches the tour's toolbar tab behavior.
*
* It uses the query string for:
* - tour: When ?tour=1 is present, the tour will start automatically after
* the page has loaded.
* - tips: Pass ?tips=class in the url to filter the available tips to the
* subset which match the given class.
*
* @example
* http://example.com/foo?tour=1&tips=bar
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attach tour functionality on `tour` events.
*/
Drupal.behaviors.tour = {
attach(context) {
once('tour', 'body').forEach(() => {
const model = new Drupal.tour.models.StateModel();
// eslint-disable-next-line no-new
new Drupal.tour.views.ToggleTourView({
el: $(context).find('#toolbar-tab-tour'),
model,
});
model
// Allow other scripts to respond to tour events.
.on('change:isActive', (tourModel, isActive) => {
$(document).trigger(
isActive ? 'drupalTourStarted' : 'drupalTourStopped',
);
});
// Initialization: check whether a tour is available on the current
// page.
if (settings._tour_internal) {
model.set('tour', settings._tour_internal);
}
// Start the tour immediately if toggled via query string.
if (/tour=?/i.test(queryString)) {
model.set('isActive', true);
}
});
},
};
/**
* @namespace
*/
Drupal.tour = Drupal.tour || {
/**
* @namespace Drupal.tour.models
*/
models: {},
/**
* @namespace Drupal.tour.views
*/
views: {},
};
/**
* Backbone Model for tours.
*
* @constructor
*
* @augments Backbone.Model
*/
Drupal.tour.models.StateModel = Backbone.Model.extend(
/** @lends Drupal.tour.models.StateModel# */ {
/**
* @type {object}
*/
defaults: /** @lends Drupal.tour.models.StateModel# */ {
/**
* Indicates whether the Drupal root window has a tour.
*
* @type {Array}
*/
tour: [],
/**
* Indicates whether the tour is currently running.
*
* @type {boolean}
*/
isActive: false,
/**
* Indicates which tour is the active one (necessary to cleanly stop).
*
* @type {Array}
*/
activeTour: [],
},
},
);
Drupal.tour.views.ToggleTourView = Backbone.View.extend(
/** @lends Drupal.tour.views.ToggleTourView# */ {
/**
* @type {object}
*/
events: { click: 'onClick' },
/**
* Handles edit mode toggle interactions.
*
* @constructs
*
* @augments Backbone.View
*/
initialize() {
this.listenTo(this.model, 'change:tour change:isActive', this.render);
this.listenTo(this.model, 'change:isActive', this.toggleTour);
},
/**
* {@inheritdoc}
*
* @return {Drupal.tour.views.ToggleTourView}
* The `ToggleTourView` view.
*/
render() {
// Render the visibility.
this.$el.toggleClass('hidden', this._getTour().length === 0);
// Render the state.
const isActive = this.model.get('isActive');
this.$el
.find('button')
.toggleClass('is-active', isActive)
.attr('aria-pressed', isActive);
return this;
},
/**
* Model change handler; starts or stops the tour.
*/
toggleTour() {
if (this.model.get('isActive')) {
this._removeIrrelevantTourItems(this._getTour());
const tourItems = this.model.get('tour');
const that = this;
if (tourItems.length) {
// If Joyride is positioned relative to the top or bottom of an
// element, and its secondary position is right or left, then the
// arrow is also positioned right or left. Shepherd defaults to
// center positioning the arrow.
//
// In most cases, this arrow positioning difference has
// little impact. However, tours built with Joyride may have tips
// using a higher level selector than the element the tip is
// expected to point to, and relied on Joyride's arrow positioning
// to align the arrow with the expected reference element. Joyride's
// arrow positioning behavior is replicated here to prevent those
// use cases from causing UI regressions.
//
// This modifier is provided here instead of TourViewBuilder (where
// most position modifications are) because it includes adding a
// JavaScript callback function.
settings.tourShepherdConfig.defaultStepOptions.popperOptions.modifiers.push(
{
name: 'moveArrowJoyridePosition',
enabled: true,
phase: 'write',
fn({ state }) {
const { arrow } = state.elements;
const { placement } = state;
if (
arrow &&
/^top|bottom/.test(placement) &&
/-start|-end$/.test(placement)
) {
const horizontalPosition = placement.split('-')[1];
const offset =
horizontalPosition === 'start'
? 28
: state.elements.popper.clientWidth - 56;
arrow.style.transform = `translate3d(${offset}px, 0px, 0px)`;
}
},
},
);
const shepherdTour = new Shepherd.Tour(settings.tourShepherdConfig);
shepherdTour.on('cancel', () => {
that.model.set('isActive', false);
});
shepherdTour.on('complete', () => {
that.model.set('isActive', false);
});
tourItems.forEach((tourStepConfig, index) => {
// Create the configuration for a given tour step by using values
// defined in TourViewBuilder.
// @see \Drupal\tour\TourViewBuilder::viewMultiple()
const tourItemOptions = {
title: tourStepConfig.title
? Drupal.checkPlain(tourStepConfig.title)
: null,
text: () => Drupal.theme('tourItemContent', tourStepConfig),
attachTo: tourStepConfig.attachTo,
buttons: [Drupal.tour.nextButton(shepherdTour, tourStepConfig)],
classes: tourStepConfig.classes,
index,
};
tourItemOptions.when = {
show() {
const nextButton =
shepherdTour.currentStep.el.querySelector('footer button');
// Drupal disables Shepherd's built in focus after item
// creation functionality due to focus being set on the tour
// item container after every scroll and resize event. In its
// place, the 'next' button is focused here.
nextButton.focus();
// When Stable 9 is part of the active theme, the
// Drupal.tour.convertToJoyrideMarkup() function is available.
// This function converts Shepherd markup to Joyride markup,
// facilitating the use of the Shepherd library that is
// backwards compatible with customizations intended for
// Joyride.
// The Drupal.tour.convertToJoyrideMarkup() function is
// internal, and will eventually be removed from Drupal core.
if (Drupal.tour.hasOwnProperty('convertToJoyrideMarkup')) {
Drupal.tour.convertToJoyrideMarkup(shepherdTour);
}
},
};
shepherdTour.addStep(tourItemOptions);
});
shepherdTour.start();
this.model.set({ isActive: true, activeTour: shepherdTour });
}
} else {
this.model.get('activeTour').cancel();
this.model.set({ isActive: false, activeTour: [] });
}
},
/**
* Toolbar tab click event handler; toggles isActive.
*
* @param {jQuery.Event} event
* The click event.
*/
onClick(event) {
this.model.set('isActive', !this.model.get('isActive'));
event.preventDefault();
event.stopPropagation();
},
/**
* Gets the tour.
*
* @return {array}
* An array of Shepherd tour item objects.
*/
_getTour() {
return this.model.get('tour');
},
/**
* Removes tour items for elements that don't have matching page elements.
*
* Or that are explicitly filtered out via the 'tips' query string.
*
* @example
* <caption>This will filter out tips that do not have a matching
* page element or don't have the "bar" class.</caption>
* http://example.com/foo?tips=bar
*
* @param {Object[]} tourItems
* An array containing tour Step config objects.
* The object properties relevant to this function:
* - classes {string}: A string of classes to be added to the tour step
* when rendered.
* - selector {string}: The selector a tour step is associated with.
*/
_removeIrrelevantTourItems(tourItems) {
const tips = /tips=([^&]+)/.exec(queryString);
const filteredTour = tourItems.filter((tourItem) => {
// If the query parameter 'tips' is set, remove all tips that don't
// have the matching class. The `tourItem` variable is a step config
// object, and the 'classes' property is a ShepherdJS Step() config
// option that provides a string.
if (
tips &&
tourItem.hasOwnProperty('classes') &&
tourItem.classes.indexOf(tips[1]) === -1
) {
return false;
}
// If a selector is configured but there isn't a matching element,
// return false.
return !(
tourItem.selector && !document.querySelector(tourItem.selector)
);
});
// If there are tours filtered, we'll have to update model.
if (tourItems.length !== filteredTour.length) {
filteredTour.forEach((filteredTourItem, filteredTourItemId) => {
filteredTour[filteredTourItemId].counter = Drupal.t(
'!tour_item of !total',
{
'!tour_item': filteredTourItemId + 1,
'!total': filteredTour.length,
},
);
if (filteredTourItemId === filteredTour.length - 1) {
filteredTour[filteredTourItemId].cancelText =
Drupal.t('End tour');
}
});
this.model.set('tour', filteredTour);
}
},
},
);
/**
* Provides an object that will become the tour item's 'next' button.
*
* Similar to a theme function, themes can override this function to customize
* the resulting button. Unlike a theme function, it returns an object instead
* of a string, which is why it is not part of Drupal.theme.
*
* @param {Tour} shepherdTour
* A class representing a Shepherd site tour.
* @param {Object} tourStepConfig
* An object generated in TourViewBuilder used for creating the options
* passed to `Tour.addStep(options)`.
* Contains the following properties:
* - id {string}: The tour.tip ID specified by its config
* - selector {string|null}: The selector of the element the tour step is
* attaching to.
* - module {string}: The module providing the tip plugin used by this step.
* - counter {string}: A string indicating which tour step this is out of
* how many total steps.
* - attachTo {Object} This is directly mapped to the `attachTo` Step()
* option. It has two properties:
* - element {string}: The selector of the element the step attaches to.
* - on {string}: a PopperJS compatible string to specify step position.
* - classes {string}: Will be added to the class attribute of the step.
* - body {string}: Markup that is mapped to the `text` Step() option. Will
* become the step content.
* - title {string}: is mapped to the `title` Step() option.
*
* @return {{classes: string, action: string, text: string}}
* An object structured in the manner Shepherd requires to create the
* 'next' button.
*
* @see https://shepherdjs.dev/docs/Tour.html
* @see \Drupal\tour\TourViewBuilder::viewMultiple()
* @see https://shepherdjs.dev/docs/Step.html
*/
Drupal.tour.nextButton = (shepherdTour, tourStepConfig) => {
return {
classes: 'button button--primary',
text: tourStepConfig.cancelText
? tourStepConfig.cancelText
: Drupal.t('Next'),
action: tourStepConfig.cancelText
? shepherdTour.cancel
: shepherdTour.next,
};
};
/**
* Theme function for tour item content.
*
* @param {Object} tourStepConfig
* An object generated in TourViewBuilder used for creating the options
* passed to `Tour.addStep(options)`.
* Contains the following properties:
* - id {string}: The tour.tip ID specified by its config
* - selector {string|null}: The selector of the element the tour step is
* attaching to.
* - module {string}: The module providing the tip plugin used by this step.
* - counter {string}: A string indicating which tour step this is out of
* how many total steps.
* - attachTo {Object} This is directly mapped to the `attachTo` Step()
* option. It has two properties:
* - element {string}: The selector of the element the step attaches to.
* - on {string}: a PopperJS compatible string to specify step position.
* - classes {string}: Will be added to the class attribute of the step.
* - body {string}: Markup that is mapped to the `text` Step() option. Will
* become the step content.
* - title {string}: is mapped to the `title` Step() option.
*
* @return {string}
* The tour item content markup.
*
* @see \Drupal\tour\TourViewBuilder::viewMultiple()
* @see https://shepherdjs.dev/docs/Step.html
*/
Drupal.theme.tourItemContent = (tourStepConfig) =>
`${tourStepConfig.body}<div class="tour-progress">${tourStepConfig.counter}</div>`;
})(jQuery, Backbone, Drupal, drupalSettings, document, window.Shepherd);

View File

@@ -0,0 +1,39 @@
<?php
namespace Drupal\tour\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a tour item annotation object.
*
* Plugin Namespace: Plugin\tour\tip
*
* For a working example, see \Drupal\tour\Plugin\tour\tip\TipPluginText
*
* @see \Drupal\tour\TipPluginBase
* @see \Drupal\tour\TipPluginInterface
* @see \Drupal\tour\TipPluginManager
* @see plugin_api
*
* @Annotation
*/
class Tip extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The title of the plugin.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $title;
}

View File

@@ -0,0 +1,189 @@
<?php
namespace Drupal\tour\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\tour\TipsPluginCollection;
use Drupal\tour\TourInterface;
/**
* Defines the configured tour entity.
*
* @ConfigEntityType(
* id = "tour",
* label = @Translation("Tour"),
* label_collection = @Translation("Tours"),
* label_singular = @Translation("tour"),
* label_plural = @Translation("tours"),
* label_count = @PluralTranslation(
* singular = "@count tour",
* plural = "@count tours",
* ),
* handlers = {
* "view_builder" = "Drupal\tour\TourViewBuilder",
* "access" = "Drupal\tour\TourAccessControlHandler",
* },
* admin_permission = "administer site configuration",
* entity_keys = {
* "id" = "id",
* "label" = "label"
* },
* config_export = {
* "id",
* "label",
* "module",
* "routes",
* "tips",
* },
* lookup_keys = {
* "routes.*.route_name"
* }
* )
*/
class Tour extends ConfigEntityBase implements TourInterface {
/**
* The name (plugin ID) of the tour.
*
* @var string
*/
protected $id;
/**
* The module which this tour is assigned to.
*
* @var string
*/
protected $module;
/**
* The label of the tour.
*
* @var string
*/
protected $label;
/**
* The routes on which this tour should be displayed.
*
* @var array
*/
protected $routes = [];
/**
* The routes on which this tour should be displayed, keyed by route id.
*
* @var array
*/
protected $keyedRoutes;
/**
* Holds the collection of tips that are attached to this tour.
*
* @var \Drupal\tour\TipsPluginCollection
*/
protected $tipsCollection;
/**
* The array of plugin config, only used for export and to populate the $tipsCollection.
*
* @var array
*/
protected $tips = [];
/**
* {@inheritdoc}
*/
public function __construct(array $values, $entity_type) {
parent::__construct($values, $entity_type);
$this->tipsCollection = new TipsPluginCollection(\Drupal::service('plugin.manager.tour.tip'), $this->tips);
}
/**
* {@inheritdoc}
*/
public function getRoutes() {
return $this->routes;
}
/**
* {@inheritdoc}
*/
public function getTip($id) {
return $this->tipsCollection->get($id);
}
/**
* {@inheritdoc}
*/
public function getTips() {
$tips = [];
foreach ($this->tips as $id => $tip) {
$tips[] = $this->getTip($id);
}
uasort($tips, function ($a, $b) {
return $a->getWeight() <=> $b->getWeight();
});
\Drupal::moduleHandler()->alter('tour_tips', $tips, $this);
return array_values($tips);
}
/**
* {@inheritdoc}
*/
public function getModule() {
return $this->module;
}
/**
* {@inheritdoc}
*/
public function hasMatchingRoute($route_name, $route_params) {
if (!isset($this->keyedRoutes)) {
$this->keyedRoutes = [];
foreach ($this->getRoutes() as $route) {
$this->keyedRoutes[$route['route_name']] = $route['route_params'] ?? [];
}
}
if (!isset($this->keyedRoutes[$route_name])) {
// We don't know about this route.
return FALSE;
}
if (empty($this->keyedRoutes[$route_name])) {
// We don't need to worry about route params, the route name is enough.
return TRUE;
}
foreach ($this->keyedRoutes[$route_name] as $key => $value) {
// If a required param is missing or doesn't match, return FALSE.
if (empty($route_params[$key]) || $route_params[$key] !== $value) {
return FALSE;
}
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function resetKeyedRoutes() {
unset($this->keyedRoutes);
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
parent::calculateDependencies();
foreach ($this->tipsCollection as $instance) {
$definition = $instance->getPluginDefinition();
$this->addDependency('module', $definition['provider']);
}
$this->addDependency('module', $this->module);
return $this;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Drupal\tour\Plugin\HelpSection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Url;
use Drupal\help\Plugin\HelpSection\HelpSectionPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides the tours list section for the help page.
*
* @HelpSection(
* id = "tour",
* title = @Translation("Tours"),
* weight = 10,
* description = @Translation("Tours guide you through workflows or explain concepts on various user interface pages. The tours with links in this list are on user interface landing pages; the tours without links will show on individual pages (such as when editing a View using the Views UI module). Available tours:"),
* permission = "access tour"
* )
*/
class TourHelpSection extends HelpSectionPluginBase implements ContainerFactoryPluginInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a TourHelpSection object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->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 getCacheMaxAge() {
// The calculation of which URL (if any) gets put on which tour depends
// on a route access check. This can have a lot of inputs, including user
// permissions and other factors. Rather than doing a complicated
// accounting of the cache metadata for all of these possible factors, set
// the max age of the cache to zero to prevent using incorrect cached
// information.
return 0;
}
/**
* {@inheritdoc}
*/
public function listTopics() {
/** @var \Drupal\tour\TourInterface[] $tours */
$tours = $this->entityTypeManager->getStorage('tour')->loadMultiple();
// Sort in the manner defined by Tour.
uasort($tours, ['Drupal\tour\Entity\Tour', 'sort']);
// Make a link to each tour, using the first of its routes that can
// be linked to by this user, if any.
$topics = [];
foreach ($tours as $tour) {
$title = $tour->label();
$id = $tour->id();
$routes = $tour->getRoutes();
$made_link = FALSE;
foreach ($routes as $route) {
// Some tours are for routes with parameters. For instance, there is
// currently a tour in the Language module for the language edit page,
// which appears on all pages with URLs like:
// /admin/config/regional/language/edit/LANGCODE.
// There is no way to make a link to the page that displays the tour,
// because it is a set of pages. The easiest way to detect this is to
// use a try/catch exception -- try to make a link, and it will error
// out with a missing parameter exception if the route leads to a set
// of pages instead of a single page.
try {
$params = $route['route_params'] ?? [];
$url = Url::fromRoute($route['route_name'], $params);
// Skip this route if the current user cannot access it.
if (!$url->access()) {
continue;
}
// Generate the link HTML directly, using toString(), to catch
// missing parameter exceptions now instead of at render time.
$topics[$id] = Link::fromTextAndUrl($title, $url)->toString();
// If the line above didn't generate an exception, we have a good
// link that the user can access.
$made_link = TRUE;
break;
}
catch (\Exception $e) {
// Exceptions are normally due to routes that need parameters. If
// there is an exception, just try the next route and see if we can
// find one that will work for us.
}
}
if (!$made_link) {
// None of the routes worked to make a link, so at least display the
// tour title.
$topics[$id] = $title;
}
}
return $topics;
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Drupal\tour\Plugin\tour\tip;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Utility\Token;
use Drupal\tour\TipPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Displays some text as a tip.
*
* @Tip(
* id = "text",
* title = @Translation("Text")
* )
*/
class TipPluginText extends TipPluginBase implements ContainerFactoryPluginInterface {
/**
* The body text which is used for render of this Text Tip.
*
* @var string
*/
protected $body;
/**
* Token service.
*
* @var \Drupal\Core\Utility\Token
*/
protected $token;
/**
* Constructs a \Drupal\tour\Plugin\tour\tip\TipPluginText object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Utility\Token $token
* The token service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->token = $token;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container->get('token'));
}
/**
* {@inheritdoc}
*/
public function getBody(): array {
return [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => $this->token->replace($this->get('body')),
'#attributes' => [
'class' => ['tour-tip-body'],
],
];
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Drupal\tour;
use Drupal\Core\Plugin\PluginBase;
/**
* Defines a base tour item implementation.
*
* @see \Drupal\tour\Annotation\Tip
* @see \Drupal\tour\TipPluginInterface
* @see \Drupal\tour\TipPluginManager
* @see plugin_api
*/
abstract class TipPluginBase extends PluginBase implements TipPluginInterface {
/**
* The label which is used for render of this tip.
*
* @var string
*/
protected $label;
/**
* Allows tips to take more priority that others.
*
* @var string
*/
protected $weight;
/**
* {@inheritdoc}
*/
public function id() {
return $this->get('id');
}
/**
* {@inheritdoc}
*/
public function getLabel() {
return $this->get('label');
}
/**
* {@inheritdoc}
*/
public function getWeight() {
return $this->get('weight');
}
/**
* {@inheritdoc}
*/
public function get($key) {
if (!empty($this->configuration[$key])) {
return $this->configuration[$key];
}
}
/**
* {@inheritdoc}
*/
public function set($key, $value) {
$this->configuration[$key] = $value;
}
/**
* {@inheritdoc}
*/
public function getLocation(): ?string {
$location = $this->get('position');
// The location values accepted by PopperJS, the library used for
// positioning the tip.
assert(in_array(trim($location ?? ''), [
'auto',
'auto-start',
'auto-end',
'top',
'top-start',
'top-end',
'bottom',
'bottom-start',
'bottom-end',
'right',
'right-start',
'right-end',
'left',
'left-start',
'left-end',
'',
], TRUE), "$location is not a valid Tour Tip position value");
return $location;
}
/**
* {@inheritdoc}
*/
public function getSelector(): ?string {
return $this->get('selector');
}
/**
* {@inheritdoc}
*/
public function getBody(): array {
return [];
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Drupal\tour;
/**
* Defines an interface for tour items.
*
* @see \Drupal\tour\Annotation\Tip
* @see \Drupal\tour\TipPluginBase
* @see \Drupal\tour\TipPluginManager
* @see plugin_api
*/
interface TipPluginInterface {
/**
* Returns id of the tip.
*
* @return string
* The id of the tip.
*/
public function id();
/**
* Returns label of the tip.
*
* @return string
* The label of the tip.
*/
public function getLabel();
/**
* Returns weight of the tip.
*
* @return string
* The weight of the tip.
*/
public function getWeight();
/**
* Used for returning values by key.
*
* @var string
* Key of the value.
*
* @return string
* Value of the key.
*/
public function get($key);
/**
* Returns the selector the tour tip will attach to.
*
* This typically maps to the Shepherd Step options `attachTo.element`
* property.
*
* @return null|string
* A selector string, or null for an unattached tip.
*
* @see https://shepherdjs.dev/docs/Step.html
*/
public function getSelector(): ?string;
/**
* Returns the body content of the tooltip.
*
* This typically maps to the Shepherd Step options `text` property.
*
* @return array
* A render array.
*
* @see https://shepherdjs.dev/docs/Step.html
*/
public function getBody(): array;
/**
* Returns the configured placement of the tip relative to the element.
*
* If null, the tip will automatically determine the best position based on
* the element's position in the viewport.
*
* This typically maps to the Shepherd Step options `attachTo.on` property.
*
* @return string|null
* The tip placement relative to the element.
*
* @see https://shepherdjs.dev/docs/Step.html
*/
public function getLocation(): ?string;
/**
* Used for returning values by key.
*
* @var string
* Key of the value.
*
* @var string
* Value of the key.
*/
public function set($key, $value);
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Drupal\tour;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
/**
* Provides a plugin manager for tour items.
*
* @see \Drupal\tour\Annotation\Tip
* @see \Drupal\tour\TipPluginBase
* @see \Drupal\tour\TipPluginInterface
* @see plugin_api
*/
class TipPluginManager extends DefaultPluginManager {
/**
* Constructs a new TipPluginManager.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations,
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/tour/tip', $namespaces, $module_handler, 'Drupal\tour\TipPluginInterface', 'Drupal\tour\Annotation\Tip');
$this->alterInfo('tour_tips_info');
$this->setCacheBackend($cache_backend, 'tour_plugins');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Drupal\tour;
use Drupal\Core\Plugin\DefaultLazyPluginCollection;
/**
* A collection of tips.
*/
class TipsPluginCollection extends DefaultLazyPluginCollection {
/**
* {@inheritdoc}
*/
protected $pluginKey = 'plugin';
/**
* {@inheritdoc}
*
* @return \Drupal\tour\TipPluginInterface
*/
public function &get($instance_id) {
return parent::get($instance_id);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Drupal\tour;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Defines the access control handler for the tour entity type.
*
* @see \Drupal\tour\Entity\Tour
*/
class TourAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
if ($operation === 'view') {
return AccessResult::allowedIfHasPermissions($account, ['access tour', 'administer site configuration'], 'OR');
}
return parent::checkAccess($entity, $operation, $account);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Drupal\tour;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Provides an interface defining a tour entity.
*/
interface TourInterface extends ConfigEntityInterface {
/**
* The routes that this tour will appear on.
*
* @return array
* Returns array of routes for the tour.
*/
public function getRoutes();
/**
* Whether the tour matches a given set of route parameters.
*
* @param string $route_name
* The route name the parameters are for.
* @param array $route_params
* Associative array of raw route params.
*
* @return bool
* TRUE if the tour matches the route parameters.
*/
public function hasMatchingRoute($route_name, $route_params);
/**
* Returns tip plugin.
*
* @param string $id
* The identifier of the tip.
*
* @return \Drupal\tour\TipPluginInterface
* The tip plugin.
*/
public function getTip($id);
/**
* Returns the tips for this tour.
*
* @return array
* An array of tip plugins.
*/
public function getTips();
/**
* Gets the module this tour belongs to.
*
* @return string
* The module this tour belongs to.
*/
public function getModule();
/**
* Resets the statically cached keyed routes.
*/
public function resetKeyedRoutes();
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Drupal\tour;
@trigger_error('The ' . __NAMESPACE__ . '\TourTipPluginInterface is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Implement ' . __NAMESPACE__ . '\TipPluginInterface instead. See https://www.drupal.org/node/3340701', E_USER_DEPRECATED);
/**
* Defines an interface for tour items.
*
* @see \Drupal\tour\Annotation\Tip
* @see \Drupal\tour\TipPluginBase
* @see \Drupal\tour\TipPluginManager
* @see plugin_api
*
* @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Implements
* TipPluginInterface instead.
*
* @see https://www.drupal.org/node/3340701
*/
interface TourTipPluginInterface extends TipPluginInterface {}

View File

@@ -0,0 +1,139 @@
<?php
namespace Drupal\tour;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityViewBuilder;
use Drupal\Component\Utility\Html;
/**
* Provides a Tour view builder.
*
* Note: Does not invoke any alter hooks. In other view
* builders, the view alter hooks are run later in the process
*/
class TourViewBuilder extends EntityViewBuilder {
/**
* {@inheritdoc}
*/
public function viewMultiple(array $entities = [], $view_mode = 'full', $langcode = NULL) {
/** @var \Drupal\tour\TourInterface[] $entities */
$tour = [];
$cache_tags = [];
$total_tips = 0;
foreach ($entities as $entity_id => $entity) {
$tour[$entity_id] = $entity->getTips();
$total_tips += count($tour[$entity_id]);
$cache_tags = Cache::mergeTags($cache_tags, $entity->getCacheTags());
}
$items = [];
foreach ($tour as $tour_id => $tips) {
$tourEntity = $entities[$tour_id];
foreach ($tips as $index => $tip) {
$classes = [
'tip-module-' . Html::getClass($tourEntity->getModule()),
'tip-type-' . Html::getClass($tip->getPluginId()),
'tip-' . Html::getClass($tip->id()),
];
$selector = $tip->getSelector();
$location = $tip->getLocation();
$body_render_array = $tip->getBody();
$body = (string) \Drupal::service('renderer')->renderInIsolation($body_render_array);
$output = [
'body' => $body,
'title' => $tip->getLabel(),
];
$selector = $tip->getSelector();
if ($output) {
$items[] = [
'id' => $tip->id(),
'selector' => $selector,
'module' => $tourEntity->getModule(),
'type' => $tip->getPluginId(),
'counter' => $this->t('@tour_item of @total', [
'@tour_item' => $index + 1,
'@total' => $total_tips,
]),
'attachTo' => [
'element' => $selector,
'on' => $location ?? 'bottom-start',
],
// Shepherd expects classes to be provided as a string.
'classes' => implode(' ', $classes),
] + $output;
}
}
}
// If there is at least one tour item, build the tour.
if ($items) {
$key = array_key_last($items);
$items[$key]['cancelText'] = t('End tour');
}
$build = [
'#cache' => [
'tags' => $cache_tags,
],
];
// If at least one tour was built, attach tips and the tour library.
if ($items) {
$build['#attached']['drupalSettings']['tourShepherdConfig'] = [
'defaultStepOptions' => [
'classes' => 'drupal-tour',
'cancelIcon' => [
'enabled' => TRUE,
'label' => $this->t('Close'),
],
'modalOverlayOpeningPadding' => 3,
'scrollTo' => [
'behavior' => 'smooth',
'block' => 'center',
],
'popperOptions' => [
'modifiers' => [
// Prevent overlap with the element being highlighted.
[
'name' => 'offset',
'options' => [
'offset' => [-10, 20],
],
],
// Pad the arrows so they don't hit the edge of rounded corners.
[
'name' => 'arrow',
'options' => [
'padding' => 12,
],
],
// Disable Shepherd's focusAfterRender modifier, which results in
// the tour item container being focused on any scroll or resize
// event.
[
'name' => 'focusAfterRender',
'enabled' => FALSE,
],
],
],
],
'useModalOverlay' => TRUE,
];
// This property is used for storing the tour items. It may change without
// notice and should not be extended or modified in contrib.
// see: https://www.drupal.org/project/drupal/issues/3214593
$build['#attached']['drupalSettings']['_tour_internal'] = $items;
$build['#attached']['library'][] = 'tour/tour';
}
return $build;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Block;
use Drupal\Tests\tour\Functional\TourTestBase;
/**
* Tests the Block Layout tour.
*
* @group tour
* @group legacy
*/
class BlockLayoutTourTest extends TourTestBase {
/**
* An admin user with administrative permissions for Blocks.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['block', 'tour'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->adminUser = $this->drupalCreateUser(['administer blocks', 'access tour']);
$this->drupalLogin($this->adminUser);
$this->drupalPlaceBlock('local_actions_block');
}
/**
* Tests Block Layout tour tip availability.
*/
public function testBlockLayoutTourTips(): void {
$this->drupalGet('admin/structure/block');
$this->assertTourTips();
}
}

View File

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

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Jsonapi;
use Drupal\Core\Url;
use Drupal\Tests\jsonapi\Functional\ConfigEntityResourceTestBase;
use Drupal\tour\Entity\Tour;
/**
* JSON:API integration test for the "Tour" config entity type.
*
* @group tour
* @group legacy
*/
class TourTest extends ConfigEntityResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['tour'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'tour';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'tour--tour';
/**
* {@inheritdoc}
*
* @var \Drupal\tour\TourInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['access tour']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$tour = Tour::create([
'id' => 'tour-llama',
'label' => 'Llama tour',
'langcode' => 'en',
'module' => 'tour',
'routes' => [
[
'route_name' => '<front>',
],
],
'tips' => [
'tour-llama-1' => [
'id' => 'tour-llama-1',
'plugin' => 'text',
'label' => 'Llama',
'body' => 'Who handle the awesomeness of llamas?',
'weight' => 100,
'selector' => '#tour-llama-1',
],
],
]);
$tour->save();
return $tour;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/tour/tour/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'tour--tour',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'label' => 'Llama tour',
'langcode' => 'en',
'module' => 'tour',
'routes' => [
[
'route_name' => '<front>',
],
],
'status' => TRUE,
'tips' => [
'tour-llama-1' => [
'id' => 'tour-llama-1',
'plugin' => 'text',
'label' => 'Llama',
'body' => 'Who handle the awesomeness of llamas?',
'weight' => 100,
'selector' => '#tour-llama-1',
],
],
'drupal_internal__id' => 'tour-llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
return [];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The following permissions are required: 'access tour' OR 'administer site configuration'.";
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Language;
use Drupal\Tests\tour\Functional\TourTestBase;
/**
* Tests tour functionality.
*
* @group tour
* @group legacy
*/
class LanguageTourTest extends TourTestBase {
/**
* An admin user with administrative permissions for views.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['block', 'language', 'tour'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->adminUser = $this->drupalCreateUser([
'administer languages',
'access tour',
]);
$this->drupalLogin($this->adminUser);
$this->drupalPlaceBlock('local_actions_block');
}
/**
* Tests language tour tip availability.
*/
public function testLanguageTour(): void {
$this->drupalGet('admin/config/regional/language');
$this->assertTourTips();
}
/**
* Go to add language page and check the tour tooltips.
*/
public function testLanguageAddTour(): void {
$this->drupalGet('admin/config/regional/language/add');
$this->assertTourTips();
}
/**
* Go to edit language page and check the tour tooltips.
*/
public function testLanguageEditTour(): void {
$this->drupalGet('admin/config/regional/language/edit/en');
$this->assertTourTips();
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Locale;
use Drupal\Tests\tour\Functional\TourTestBase;
/**
* Tests the Translate Interface tour.
*
* @group tour
* @group legacy
*/
class LocaleTranslateStringTourTest extends TourTestBase {
/**
* An admin user with administrative permissions to translate.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['locale', 'tour'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->adminUser = $this->drupalCreateUser([
'translate interface',
'access tour',
'administer languages',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Tests locale tour tip availability.
*/
public function testTranslateStringTourTips(): void {
// Add another language so there are no missing form items.
$edit = [];
$edit['predefined_langcode'] = 'es';
$this->drupalGet('admin/config/regional/language/add');
$this->submitForm($edit, 'Add language');
$this->drupalGet('admin/config/regional/translate');
$this->assertTourTips();
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
* @group legacy
*/
class TourJsonAnonTest extends TourResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group rest
* @group legacy
*/
class TourJsonBasicAuthTest extends TourResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group rest
* @group legacy
*/
class TourJsonCookieTest extends TourResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Rest;
use Drupal\Tests\rest\Functional\EntityResource\ConfigEntityResourceTestBase;
use Drupal\tour\Entity\Tour;
abstract class TourResourceTestBase extends ConfigEntityResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['tour'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'tour';
/**
* @var \Drupal\tour\TourInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['access tour']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$tour = Tour::create([
'id' => 'tour-llama',
'label' => 'Llama tour',
'langcode' => 'en',
'module' => 'tour',
'routes' => [
[
'route_name' => '<front>',
],
],
'tips' => [
'tour-llama-1' => [
'id' => 'tour-llama-1',
'plugin' => 'text',
'label' => 'Llama',
'body' => 'Who handle the awesomeness of llamas?',
'weight' => 100,
'selector' => '#tour-llama-1',
],
],
]);
$tour->save();
return $tour;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return [
'dependencies' => [],
'id' => 'tour-llama',
'label' => 'Llama tour',
'langcode' => 'en',
'module' => 'tour',
'routes' => [
[
'route_name' => '<front>',
],
],
'status' => TRUE,
'tips' => [
'tour-llama-1' => [
'id' => 'tour-llama-1',
'plugin' => 'text',
'label' => 'Llama',
'body' => 'Who handle the awesomeness of llamas?',
'weight' => 100,
'selector' => '#tour-llama-1',
],
],
'uuid' => $this->entity->uuid(),
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
// @todo Update in https://www.drupal.org/node/2300677.
return [];
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts() {
return [
'user.permissions',
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The following permissions are required: 'access tour' OR 'administer site configuration'.";
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
* @group legacy
*/
class TourXmlAnonTest extends TourResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
* @group legacy
*/
class TourXmlBasicAuthTest extends TourResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
* @group legacy
*/
class TourXmlCookieTest extends TourResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional;
use Drupal\Core\Url;
use Drupal\Tests\system\Functional\Cache\PageCacheTagsTestBase;
use Drupal\tour\Entity\Tour;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
/**
* Tests the Tour entity's cache tags.
*
* @group tour
* @group legacy
*/
class TourCacheTagsTest extends PageCacheTagsTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['tour', 'tour_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Give anonymous users permission to view nodes, so that we can verify the
// cache tags of cached versions of node pages.
Role::load(RoleInterface::ANONYMOUS_ID)->grantPermission('access tour')
->save();
}
/**
* Tests cache tags presence and invalidation of the Tour entity.
*
* Tests the following cache tags:
* - 'tour:<tour ID>'
*/
public function testRenderedTour(): void {
$url = Url::fromRoute('tour_test.1');
// Prime the page cache.
$this->verifyPageCache($url, 'MISS');
// Verify a cache hit, but also the presence of the correct cache tags.
$expected_tags = [
'config:tour.tour.tour-test',
'config:user.role.anonymous',
'http_response',
'rendered',
];
$this->verifyPageCache($url, 'HIT', $expected_tags);
// Verify that after modifying the tour, there is a cache miss.
Tour::load('tour-test')->save();
$this->verifyPageCache($url, 'MISS');
// Verify a cache hit.
$this->verifyPageCache($url, 'HIT', $expected_tags);
// Verify that after deleting the tour, there is a cache miss.
Tour::load('tour-test')->delete();
$this->verifyPageCache($url, 'MISS');
// Verify a cache hit.
$expected_tags = [
'config:user.role.anonymous',
'http_response',
'rendered',
];
$this->verifyPageCache($url, 'HIT', $expected_tags);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Verifies help page display of tours.
*
* @group help
* @group legacy
*/
class TourHelpPageTest extends BrowserTestBase {
/**
* Modules to enable, including some providing tours.
*
* @var array
*/
protected static $modules = ['help', 'tour', 'locale', 'language'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* User that can access tours and help.
*
* @var \Drupal\user\UserInterface
*/
protected $tourUser;
/**
* A user who can access help but not tours.
*
* @var \Drupal\user\UserInterface
*/
protected $noTourUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create users. For the Tour user, include permissions for the language
// tours' parent pages, but not the translation tour's parent page. See
// self:getTourList().
$this->tourUser = $this->drupalCreateUser([
'access help pages',
'access tour',
'administer languages',
]);
$this->noTourUser = $this->drupalCreateUser([
'access help pages',
]);
}
/**
* Logs in users, tests help pages.
*/
public function testHelp(): void {
$this->drupalLogin($this->tourUser);
$this->verifyHelp();
$this->drupalLogin($this->noTourUser);
$this->verifyHelp(FALSE);
}
/**
* Verifies the logged in user has access to the help properly.
*
* @param bool $tours_ok
* (optional) TRUE (default) if the user should see tours, FALSE if not.
*/
protected function verifyHelp($tours_ok = TRUE) {
$this->drupalGet('admin/help');
// All users should be able to see the module section.
$this->assertSession()->pageTextContains('Module overviews are provided by modules');
foreach ($this->getModuleList() as $name) {
$this->assertSession()->linkExists($name);
}
// Some users should be able to see the tour section.
if ($tours_ok) {
$this->assertSession()->pageTextContains('Tours guide you through workflows');
}
else {
$this->assertSession()->pageTextNotContains('Tours guide you through workflows');
}
$titles = $this->getTourList();
// Test the titles that should be links.
foreach ($titles[0] as $title) {
if ($tours_ok) {
$this->assertSession()->linkExists($title);
}
else {
$this->assertSession()->linkNotExists($title);
// Just test the first item in the list of links that should not
// be there, because the second matches the name of a module that is
// in the Module overviews section, so the link will be there and
// this test will fail. Testing one should be sufficient to verify
// the page is working correctly.
break;
}
}
// Test the titles that should not be links.
foreach ($titles[1] as $title) {
if ($tours_ok) {
$this->assertSession()->pageTextContains($title);
$this->assertSession()->linkNotExistsExact($title);
}
else {
$this->assertSession()->pageTextNotContains($title);
// Just test the first item in the list of text that should not
// be there, because the second matches part of the name of a module
// that is in the Module overviews section, so the text will be there
// and this test will fail. Testing one should be sufficient to verify
// the page is working correctly.
break;
}
}
}
/**
* Gets a list of modules to test for hook_help() pages.
*
* @return array
* A list of module names to test.
*/
protected function getModuleList() {
return ['Help', 'Tour'];
}
/**
* Gets a list of tours to test.
*
* @return array
* A list of tour titles to test. The first array element is a list of tours
* with links, and the second is a list of tours without links. Assumes
* that the user being tested has 'administer languages' permission but
* not 'translate interface'.
*/
protected function getTourList() {
return [['Adding languages', 'Language'], ['Editing languages', 'Translation']];
}
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\tour\Entity\Tour;
// cspell:ignore pioggia spagna
/**
* Tests the functionality of tour tips.
*
* @group tour
* @group legacy
*/
class TourTest extends TourTestBasic {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'block',
'tour',
'locale',
'language',
'tour_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The permissions required for a logged in user to test tour tips.
*
* @var array
* A list of permissions.
*/
protected $permissions = ['access tour', 'administer languages'];
/**
* Tour tip attributes to be tested. Keyed by the path.
*
* @var array
* An array of tip attributes, keyed by path.
*/
protected $tips = [
'tour-test-1' => [],
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalPlaceBlock('local_actions_block', [
'theme' => 'claro',
'region' => 'content',
]);
}
/**
* Tests tour functionality.
*/
public function testTourFunctionality(): void {
// Navigate to tour-test-1 and verify the tour_test_1 tip is found with appropriate classes.
$this->drupalGet('tour-test-1');
// Test the TourTestBase class assertTourTips() method.
$tips = [];
$tips[] = ['data-id' => 'tour-test-1'];
$tips[] = ['data-class' => 'tour-test-5'];
$this->assertTourTips($tips);
$this->assertTourTips();
$tips = $this->getTourTips();
$href = Url::fromRoute('<front>', [], ['absolute' => TRUE])->toString();
$elements = [];
foreach ($tips as $tip) {
if ($tip['id'] == 'tour-test-1' && $tip['module'] == 'tour_test' && $tip['type'] == 'text' && str_contains($tip['body'], $href) && str_contains($tip['body'], 'Drupal')) {
$elements[] = $tip;
}
}
$this->assertCount(1, $elements, 'Found Token replacement.');
$elements = $this->findTip([
'id' => 'tour-test-1',
'title' => 'The first tip',
]);
$this->assertCount(1, $elements, 'Found English variant of tip 1.');
$elements = $this->findTip([
'id' => 'tour-test-2',
'title' => 'The quick brown fox',
]);
$this->assertNotCount(1, $elements, 'Did not find English variant of tip 2.');
$elements = $this->findTip([
'id' => 'tour-test-1',
'title' => 'La pioggia cade in spagna',
]);
$this->assertNotCount(1, $elements, 'Did not find Italian variant of tip 1.');
// Ensure that plugins work.
$elements = [];
foreach ($tips as $tip) {
if (str_contains($tip['body'], 'http://local/image.png')) {
$elements[] = $tip;
}
}
$this->assertCount(1, $elements, 'Image plugin tip found.');
// Navigate to tour-test-2/subpath and verify the tour_test_2 tip is found.
$this->drupalGet('tour-test-2/subpath');
$elements = $this->findTip([
'id' => 'tour-test-2',
'title' => 'The quick brown fox',
]);
$this->assertCount(1, $elements, 'Found English variant of tip 2.');
$elements = $this->findTip([
'id' => 'tour-test-1',
'title' => 'The first tip',
]);
$this->assertNotCount(1, $elements, 'Did not find English variant of tip 1.');
// Enable Italian language and navigate to it/tour-test1 and verify italian
// version of tip is found.
ConfigurableLanguage::createFromLangcode('it')->save();
$this->drupalGet('it/tour-test-1');
$elements = $this->findTip([
'id' => 'tour-test-1',
'title' => 'La pioggia cade in spagna',
]);
$this->assertCount(1, $elements, 'Found Italian variant of tip 1.');
$elements = $this->findTip([
'id' => 'tour-test-2',
'title' => 'The quick brown fox',
]);
$this->assertNotCount(1, $elements, 'Did not find English variant of tip 1.');
// Programmatically create a tour for use through the remainder of the test.
$tour = Tour::create([
'id' => 'tour-entity-create-test-en',
'label' => 'Tour test english',
'langcode' => 'en',
'module' => 'system',
'routes' => [
['route_name' => 'tour_test.1'],
],
'tips' => [
'tour-test-1' => [
'id' => 'tour-code-test-1',
'plugin' => 'text',
'label' => 'The rain in spain is <strong>strong</strong>',
'body' => 'Falls mostly on the plain.',
'weight' => '100',
'selector' => '#tour-code-test-1',
],
'tour-code-test-2' => [
'id' => 'tour-code-test-2',
'plugin' => 'image',
'label' => 'The awesome image',
'url' => 'http://local/image.png',
'weight' => 1,
'selector' => '#tour-code-test-2',
],
],
]);
$tour->save();
// Ensure that a tour entity has the expected dependencies based on plugin
// providers and the module named in the configuration entity.
$dependencies = $tour->calculateDependencies()->getDependencies();
$this->assertEquals(['system', 'tour_test'], $dependencies['module']);
$this->drupalGet('tour-test-1');
// Load it back from the database and verify storage worked.
$entity_save_tip = Tour::load('tour-entity-create-test-en');
// Verify that hook_ENTITY_TYPE_load() integration worked.
$this->assertEquals('Load hooks work', $entity_save_tip->loaded);
// Verify that hook_ENTITY_TYPE_presave() integration worked.
$this->assertEquals('Tour test english alter', $entity_save_tip->label());
// Navigate to tour-test-1 and verify the new tip is found.
$this->drupalGet('tour-test-1');
$elements = $this->findTip([
'id' => 'tour-code-test-1',
'title' => 'The rain in spain is <strong>strong</strong>',
]);
$this->assertCount(1, $elements, 'Found the required tip markup for tip 4');
// Verify that the weight sorting works by ensuring the lower weight item
// (tip 4) has the 'End tour' button.
$elements = $this->findTip([
'id' => 'tour-code-test-1',
'text' => 'End tour',
]);
$this->assertCount(1, $elements, 'Found code tip was weighted last and had "End tour".');
// Test hook_tour_alter().
$this->assertSession()->responseContains('Altered by hook_tour_tips_alter');
// Navigate to tour-test-3 and verify the tour_test_1 tip is found with
// appropriate classes.
$this->drupalGet('tour-test-3/foo');
$elements = $this->findTip([
'id' => 'tour-test-1',
'module' => 'tour_test',
'type' => 'text',
'title' => 'The first tip',
]);
$this->assertCount(1, $elements, 'Found English variant of tip 1.');
// Navigate to tour-test-3 and verify the tour_test_1 tip is not found with
// appropriate classes.
$this->drupalGet('tour-test-3/bar');
$elements = $this->findTip([
'id' => 'tour-test-1',
'module' => 'tour_test',
'type' => 'text',
'title' => 'The first tip',
]);
$this->assertCount(0, $elements, 'Did not find English variant of tip 1.');
}
/**
* Tests enabling and disabling the tour tip functionality.
*/
public function testStatus(): void {
// Set tour tip status as enabled.
$tour = Tour::load('tour-test');
$tour->setStatus(TRUE);
$tour->save();
$this->drupalGet('tour-test-1');
$this->assertSession()->statusCodeEquals(200);
// Tour tips should be visible on the page.
$this->assertTourTips();
$tour->setStatus(FALSE);
$tour->save();
// Navigate and verify the tour_test_1 tip is not found with
// appropriate classes.
$this->drupalGet('tour-test-1');
$this->assertSession()->statusCodeEquals(200);
// No tips expected as tour is disabled.
$this->assertTourTips(expectEmpty: TRUE);
}
/**
* Gets tour tips from the JavaScript drupalSettings variable.
*
* @return array
* A list of tips and their data.
*/
protected function getTourTips() {
$tips = [];
$drupalSettings = $this->getDrupalSettings();
if (isset($drupalSettings['_tour_internal'])) {
foreach ($drupalSettings['_tour_internal'] as $tip) {
$tips[] = $tip;
}
}
return $tips;
}
/**
* Find specific tips by their parameters in the list of tips.
*
* @param array $params
* The list of search parameters and their values.
*
* @return array
* A list of tips which match the parameters.
*/
protected function findTip(array $params) {
$tips = $this->getTourTips();
$elements = [];
foreach ($tips as $tip) {
foreach ($params as $param => $value) {
if (isset($tip[$param]) && $tip[$param] != $value) {
continue 2;
}
}
$elements[] = $tip;
}
return $elements;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Base class for testing Tour functionality.
*/
abstract class TourTestBase extends BrowserTestBase {
/**
* Asserts the presence of page elements for tour tips.
*
* @code
* // Basic example.
* $this->assertTourTips();
*
* // Advanced example. The following would be used for multi-page or
* // targeting a specific subset of tips.
* $tips = [];
* $tips[] = ['data-id' => 'foo'];
* $tips[] = ['data-id' => 'bar'];
* $tips[] = ['data-class' => 'baz'];
* $this->assertTourTips($tips);
* @endcode
*
* @param array $tips
* A list of tips which provide either a "data-id" or "data-class".
* @param bool $expectEmpty
* Whether or not the field is expected to be Empty.
*/
public function assertTourTips(array $tips = [], bool $expectEmpty = FALSE) {
// Get the rendered tips and their data-id and data-class attributes.
if (empty($tips)) {
// Tips are rendered as drupalSettings values.
$drupalSettings = $this->getDrupalSettings();
if (isset($drupalSettings['_tour_internal'])) {
foreach ($drupalSettings['_tour_internal'] as $tip) {
$tips[] = [
'selector' => $tip['selector'] ?? NULL,
];
}
}
}
$tip_count = count($tips);
if ($tip_count === 0 && $expectEmpty) {
// No tips found as expected.
return;
}
if ($tip_count > 0 && $expectEmpty) {
$this->fail("No tips were expected but $tip_count were found");
}
$this->assertGreaterThan(0, $tip_count);
// Check for corresponding page elements.
$total = 0;
$modals = 0;
foreach ($tips as $tip) {
if (!empty($tip['data-id'])) {
$elements = $this->getSession()->getPage()->findAll('css', '#' . $tip['data-id']);
$this->assertCount(1, $elements, sprintf('Found corresponding page element for tour tip with id #%s', $tip['data-id']));
}
elseif (!empty($tip['data-class'])) {
$elements = $this->getSession()->getPage()->findAll('css', '.' . $tip['data-class']);
$this->assertNotEmpty($elements, sprintf("Page element for tour tip with class .%s should be present", $tip['data-class']));
}
else {
// It's a modal.
$modals++;
}
$total++;
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional;
/**
* Simple tour tips test base.
*/
abstract class TourTestBasic extends TourTestBase {
/**
* Tour tip attributes to be tested. Keyed by the path.
*
* @var array
* An array of tip attributes, keyed by path.
*
* @code
* protected $tips = [
* '/foo/bar' => [
* ['data-id' => 'foo'],
* ['data-class' => 'bar'],
* ],
* ];
* @endcode
*/
protected $tips = [];
/**
* An admin user with administrative permissions for tour.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* The permissions required for a logged in user to test tour tips.
*
* @var array
* A list of permissions.
*/
protected $permissions = ['access tour'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Make sure we are using distinct default and administrative themes for
// the duration of these tests.
$this->container->get('theme_installer')->install(['olivero', 'claro']);
$this->config('system.theme')
->set('default', 'olivero')
->set('admin', 'claro')
->save();
$this->permissions[] = 'view the administration theme';
// Create an admin user to view tour tips.
$this->adminUser = $this->drupalCreateUser($this->permissions);
$this->drupalLogin($this->adminUser);
}
/**
* A simple tip test.
*/
public function testTips(): void {
foreach ($this->tips as $path => $attributes) {
$this->drupalGet($path);
$this->assertTourTips($attributes);
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Functional\ViewsUi;
use Drupal\Tests\tour\Functional\TourTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
/**
* Tests the Views UI tour.
*
* @group tour
* @group legacy
*/
class ViewsUITourTest extends TourTestBase {
/**
* An admin user with administrative permissions for views.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* String translation storage object.
*
* @var \Drupal\locale\StringStorageInterface
*/
protected $localeStorage;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['views_ui', 'tour', 'language', 'locale'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->adminUser = $this->drupalCreateUser([
'administer views',
'access tour',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Tests views_ui tour tip availability.
*/
public function testViewsUiTourTips(): void {
// Create a basic view that shows all content, with a page and a block
// display.
$view['label'] = $this->randomMachineName(16);
$view['id'] = $this->randomMachineName(16);
$view['page[create]'] = 1;
$view['page[path]'] = $this->randomMachineName(16);
$this->drupalGet('admin/structure/views/add');
$this->submitForm($view, 'Save and edit');
$this->assertTourTips();
}
/**
* Tests views_ui tour tip availability in a different language.
*/
public function testViewsUiTourTipsTranslated(): void {
$langcode = 'nl';
// Add a default locale storage for this test.
$this->localeStorage = $this->container->get('locale.storage');
// Add Dutch language programmatically.
ConfigurableLanguage::createFromLangcode($langcode)->save();
// Handler titles that need translations.
$handler_titles = [
'Format',
'Fields',
'Sort criteria',
'Filter criteria',
];
foreach ($handler_titles as $handler_title) {
// Create source string.
$source = $this->localeStorage->createString([
'source' => $handler_title,
]);
$source->save();
$this->createTranslation($source, $langcode);
}
// Create a basic view that shows all content, with a page and a block
// display.
$view['label'] = $this->randomMachineName(16);
$view['id'] = $this->randomMachineName(16);
$view['page[create]'] = 1;
$view['page[path]'] = $this->randomMachineName(16);
// Load the page in dutch.
$this->drupalGet($langcode . '/admin/structure/views/add');
$this->submitForm($view, 'Save and edit');
$this->assertTourTips();
}
/**
* Creates single translation for source string.
*/
public function createTranslation($source, $langcode) {
return $this->localeStorage->createTranslation([
'lid' => $source->lid,
'language' => $langcode,
'translation' => $this->randomMachineName(100),
])->save();
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* General Tour tests that require JavaScript.
*
* @group tour
* @group legacy
*/
class TourJavascriptTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'tour',
'tour_test',
'toolbar',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$admin_user = $this->drupalCreateUser([
'access toolbar',
'access tour',
]);
$this->drupalLogin($admin_user);
}
/**
* Confirm the 'tips' and 'tour 'query arguments.
*/
public function testQueryArg(): void {
$assert_session = $this->assertSession();
$this->drupalGet('tour-test-1');
$assert_session->assertNoElementAfterWait('css', '.tip-tour-test-1');
$assert_session->pageTextContains('Where does the rain in Spain fail?');
$assert_session->pageTextNotContains('Im all these things');
$assert_session->pageTextNotContains('The first tip');
$this->drupalGet('tour-test-1', [
'query' => [
'tips' => 'tip-tour-test-6',
],
]);
$this->assertNotNull($assert_session->waitForElementVisible('css', '.tip-tour-test-6'));
$assert_session->pageTextContains('Im all these things');
$this->drupalGet('tour-test-1', [
'query' => [
'tour' => '1',
],
]);
$this->assertNotNull($assert_session->waitForElementVisible('css', '.tip-tour-test-1'));
$assert_session->pageTextContains('The first tip');
}
/**
* Tests stepping through a tour.
*/
public function testGeneralTourUse(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('tour-test-1');
$assert_session->assertNoElementAfterWait('css', '.tip-tour-test-1');
// Open the tour.
$page->find('css', '#toolbar-tab-tour button')->press();
// Confirm the tour can be cancelled.
$tip_to_close = $assert_session->waitForElementVisible('css', '.shepherd-enabled.tip-tour-test-1');
$this->assertNotNull($tip_to_close);
$tip_text = $tip_to_close->getText();
$this->assertStringContainsString('always the best dressed', $tip_text);
$this->assertStringContainsString('1 of 3', $tip_text);
$this->assertStringNotContainsString('End tour', $tip_text);
// Cancel the tour.
$tip_to_close->find('css', '.shepherd-cancel-icon')->press();
$assert_session->assertNoElementAfterWait('css', '.tip-tour-test-1');
$assert_session->assertNoElementAfterWait('css', '.shepherd-enabled');
// Navigate through the three steps of the tour.
$page->find('css', '#toolbar-tab-tour button')->press();
$tip1 = $assert_session->waitForElementVisible('css', '.shepherd-enabled.tip-tour-test-1');
$this->assertNotNull($tip1);
// Click the next button.
$tip1->find('css', '.button--primary:contains("Next")')->press();
// The second tour tip should appear, confirm it has the expected content.
$tip2 = $assert_session->waitForElementVisible('css', '.shepherd-enabled.tip-tour-test-3');
$assert_session->pageTextNotContains('always the best dressed');
$tip_text = $tip2->getText();
$this->assertStringContainsString('The awesome image', $tip_text);
$this->assertStringContainsString('2 of 3', $tip_text);
$this->assertStringNotContainsString('End tour', $tip_text);
// Click the next button.
$tip2->find('css', '.button--primary:contains("Next")')->press();
// The third tour tip should appear, confirm it has the expected content.
$tip3 = $assert_session->waitForElementVisible('css', '.shepherd-enabled.tip-tour-test-6');
$assert_session->pageTextNotContains('The awesome image');
$tip_text = $tip3->getText();
$this->assertStringContainsString('Im all these things', $tip_text);
$this->assertStringContainsString('3 of 3', $tip_text);
$this->assertStringNotContainsString('Next', $tip_text);
// The final tip should have a button to end the tour. Press and confirm all
// tips removed.
$tip3->find('css', '.button--primary:contains("End tour")')->press();
$assert_session->assertNoElementAfterWait('css', '.shepherd-enabled');
$assert_session->pageTextNotContains('The awesome image');
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the functionality of tour plugins.
*
* @group tour
* @group legacy
*/
class TourPluginTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['tour'];
/**
* Stores the tour plugin manager.
*
* @var \Drupal\tour\TipPluginManager
*/
protected $pluginManager;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig(['tour']);
$this->pluginManager = $this->container->get('plugin.manager.tour.tip');
}
/**
* Tests tour plugins.
*/
public function testTourPlugins(): void {
$this->assertCount(1, $this->pluginManager->getDefinitions(), 'Only tour plugins for the enabled modules were returned.');
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Kernel;
use Drupal\tour\TourTipPluginInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
/**
* @coversDefaultClass \Drupal\tour\TourTipPluginInterface
* @group tour
* @group legacy
*/
class TourTipLegacyTest extends TestCase {
use ExpectDeprecationTrait;
public function testPluginHelperDeprecation(): void {
$this->expectDeprecation('The Drupal\tour\TourTipPluginInterface is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Implement Drupal\tour\TipPluginInterface instead. See https://www.drupal.org/node/3340701');
$plugin = $this->createMock(TourTipPluginInterface::class);
$this->assertInstanceOf(TourTipPluginInterface::class, $plugin);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Kernel;
use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase;
use Drupal\tour\Entity\Tour;
/**
* Tests validation of tour entities.
*
* @group tour
* @group legacy
*/
class TourValidationTest extends ConfigEntityValidationTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['tour'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->entity = Tour::create([
'id' => 'test',
'label' => 'Test',
'module' => 'system',
]);
$this->entity->save();
}
/**
* Tour IDs are atypical in that they allow dashes in the machine name.
*/
public static function providerInvalidMachineNameCharacters(): array {
$cases = parent::providerInvalidMachineNameCharacters();
// Remove the existing test case that verifies a machine name containing
// periods is invalid.
self::assertSame(['dash-separated', FALSE], $cases['INVALID: dash separated']);
unset($cases['INVALID: dash separated']);
// And instead add a test case that verifies it is allowed for tours.
$cases['VALID: dash separated'] = ['dash-separated', TRUE];
return $cases;
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Unit\Entity;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\tour\Entity\Tour
*
* @group tour
* @group legacy
*/
class TourTest extends UnitTestCase {
/**
* Tests \Drupal\tour\Entity\Tour::hasMatchingRoute().
*
* @param array $routes
* Array of routes as per the Tour::routes property.
* @param string $route_name
* The route name to match.
* @param array $route_params
* Array of route params.
* @param bool $result
* Expected result.
*
* @covers ::hasMatchingRoute
*
* @dataProvider routeProvider
*/
public function testHasMatchingRoute($routes, $route_name, $route_params, $result): void {
$tour = $this->getMockBuilder('\Drupal\tour\Entity\Tour')
->disableOriginalConstructor()
->onlyMethods(['getRoutes'])
->getMock();
$tour->expects($this->any())
->method('getRoutes')
->willReturn($routes);
$this->assertSame($result, $tour->hasMatchingRoute($route_name, $route_params));
$tour->resetKeyedRoutes();
}
/**
* Provides sample routes for testing.
*/
public static function routeProvider() {
return [
// Simple match.
[
[
['route_name' => 'some.route'],
],
'some.route',
[],
TRUE,
],
// Simple non-match.
[
[
['route_name' => 'another.route'],
],
'some.route',
[],
FALSE,
],
// Empty params.
[
[
[
'route_name' => 'some.route',
'route_params' => ['foo' => 'bar'],
],
],
'some.route',
[],
FALSE,
],
// Match on params.
[
[
[
'route_name' => 'some.route',
'route_params' => ['foo' => 'bar'],
],
],
'some.route',
['foo' => 'bar'],
TRUE,
],
// Non-matching params.
[
[
[
'route_name' => 'some.route',
'route_params' => ['foo' => 'bar'],
],
],
'some.route',
['bar' => 'foo'],
FALSE,
],
// One matching, one not.
[
[
[
'route_name' => 'some.route',
'route_params' => ['foo' => 'bar'],
],
[
'route_name' => 'some.route',
'route_params' => ['bar' => 'foo'],
],
],
'some.route',
['bar' => 'foo'],
TRUE,
],
// One matching, one not.
[
[
[
'route_name' => 'some.route',
'route_params' => ['foo' => 'bar'],
],
[
'route_name' => 'some.route',
'route_params' => ['foo' => 'baz'],
],
],
'some.route',
['foo' => 'baz'],
TRUE,
],
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\tour\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\tour\TipPluginBase;
/**
* @coversDefaultClass \Drupal\tour\TipPluginBase
*
* @group tour
* @group legacy
*/
class TipPluginBaseTest extends UnitTestCase {
/**
* @covers ::getLocation
*/
public function testGetLocationAssertion(): void {
$base_plugin = $this->getMockForAbstractClass(TipPluginBase::class, [], '', FALSE);
$base_plugin->set('position', 'right');
$this->assertSame('right', $base_plugin->getLocation());
$base_plugin->set('position', 'not_valid');
$this->expectException(\AssertionError::class);
$this->expectExceptionMessage('not_valid is not a valid Tour Tip position value');
$base_plugin->getLocation();
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Drupal\tour_test\Controller;
/**
* Controller routines for tour_test routes.
*/
class TourTestController {
/**
* Outputs some content for testing tours.
*
* @param string $locale
* (optional) Dummy locale variable for testing routing parameters. Defaults
* to 'foo'.
*
* @return array
* Array of markup.
*/
public function tourTest1($locale = 'foo') {
return [
'tip-1' => [
'#type' => 'container',
'#attributes' => [
'id' => 'tour-test-1',
],
'#children' => t('Where does the rain in Spain fail?'),
],
'tip-3' => [
'#type' => 'container',
'#attributes' => [
'id' => 'tour-test-3',
],
'#children' => t('Tip created now?'),
],
'tip-4' => [
'#type' => 'container',
'#attributes' => [
'id' => 'tour-test-4',
],
'#children' => t('Tip created later?'),
],
'tip-5' => [
'#type' => 'container',
'#attributes' => [
'class' => ['tour-test-5'],
],
'#children' => t('Tip created later?'),
],
'code-tip-1' => [
'#type' => 'container',
'#attributes' => [
'id' => 'tour-code-test-1',
],
'#children' => t('Tip created now?'),
],
];
}
/**
* Outputs some content for testing tours.
*/
public function tourTest2() {
return [
'#type' => 'container',
'#attributes' => [
'id' => 'tour-test-2',
],
'#children' => t('Pangram example'),
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Drupal\tour_test\Plugin\tour\tip;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Utility\Token;
use Drupal\tour\TipPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Displays an image as a tip.
*
* @Tip(
* id = "image",
* title = @Translation("Image")
* )
*/
class TipPluginImage extends TipPluginBase implements ContainerFactoryPluginInterface {
/**
* The URL which is used for the image in this Tip.
*
* @var string
* A URL used for the image.
*/
protected $url;
/**
* The alt text which is used for the image in this Tip.
*
* @var string
* An alt text used for the image.
*/
protected $alt;
/**
* Token service.
*
* @var \Drupal\Core\Utility\Token
*/
protected $token;
/**
* Constructs a \Drupal\tour\Plugin\tour\tip\TipPluginText object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Utility\Token $token
* The token service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->token = $token;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container->get('token'));
}
/**
* {@inheritdoc}
*/
public function getBody(): array {
$image = [
'#theme' => 'image',
'#uri' => $this->get('url'),
'#alt' => $this->get('alt'),
];
return [
'#type' => 'html_tag',
'#tag' => 'p',
'#attributes' => [
'class' => ['tour-tip-image'],
],
'image' => $image,
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Drupal\tour_test\Plugin\tour\tip;
use Drupal\Component\Utility\Html;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Utility\Token;
use Drupal\tour\TipPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Displays an image as a tip.
*
* @Tip(
* id = "image_legacy",
* title = @Translation("Image Legacy")
* )
*/
class TipPluginImageLegacy extends TipPluginBase implements ContainerFactoryPluginInterface {
/**
* The URL which is used for the image in this Tip.
*
* @var string
* A URL used for the image.
*/
protected $url;
/**
* The alt text which is used for the image in this Tip.
*
* @var string
* An alt text used for the image.
*/
protected $alt;
/**
* Token service.
*
* @var \Drupal\Core\Utility\Token
*/
protected $token;
/**
* Constructs a TipPluginImageLegacy object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Utility\Token $token
* The token service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->token = $token;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container->get('token'));
}
/**
* {@inheritdoc}
*/
public function getConfigurationOrNot() {
$image = [
'#theme' => 'image',
'#uri' => $this->get('url'),
'#alt' => $this->get('alt'),
];
return [
'title' => Html::escape($this->get('label')),
'body' => $this->token->replace(\Drupal::service('renderer')->renderInIsolation($image)),
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Drupal\tour_test\Plugin\tour\tip;
use Drupal\Component\Utility\Html;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Utility\Token;
use Drupal\tour\TipPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Displays some text as a tip.
*
* @Tip(
* id = "text_legacy",
* title = @Translation("Text Legacy")
* )
*/
class TipPluginTextLegacy extends TipPluginBase implements ContainerFactoryPluginInterface {
/**
* The body text which is used for render of this Text Tip.
*
* @var string
*/
protected $body;
/**
* Token service.
*
* @var \Drupal\Core\Utility\Token
*/
protected $token;
/**
* The forced position of where the tip will be located.
*
* @var string
*/
protected $location;
/**
* Unique aria-id.
*
* @var string
*/
protected $ariaId;
/**
* Constructs a TipPluginTextLegacy object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Utility\Token $token
* The token service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->token = $token;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container->get('token'));
}
/**
* Returns an ID that is guaranteed uniqueness.
*
* @return string
* A unique id to be used to generate aria attributes.
*/
public function getAriaId() {
if (!$this->ariaId) {
$this->ariaId = Html::getUniqueId($this->get('id'));
}
return $this->ariaId;
}
/**
* Returns body of the text tip.
*
* @return array
* The tip body.
*/
public function getBody(): array {
return [$this->get('body')];
}
}

View File

@@ -0,0 +1,12 @@
name: Tour module tests
type: module
description: Tests module for tour module.
package: Testing
# version: VERSION
dependencies:
- drupal:tour
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,5 @@
tour_test_action:
route_name: tour_test.1_action
title: 'Tour test action'
appears_on:
- tour_test.1

View File

@@ -0,0 +1,37 @@
<?php
/**
* @file
* Provides tests for tour module.
*/
use Drupal\Core\Entity\EntityInterface;
/**
* Implements hook_ENTITY_TYPE_load() for tour.
*/
function tour_test_tour_load($entities) {
if (isset($entities['tour-entity-create-test-en'])) {
$entities['tour-entity-create-test-en']->loaded = 'Load hooks work';
}
}
/**
* Implements hook_ENTITY_TYPE_presave() for tour.
*/
function tour_test_tour_presave($entity) {
if ($entity->id() == 'tour-entity-create-test-en') {
$entity->set('label', $entity->label() . ' alter');
}
}
/**
* Implements hook_tour_tips_alter().
*/
function tour_test_tour_tips_alter(array &$tour_tips, EntityInterface $entity) {
foreach ($tour_tips as $tour_tip) {
if ($tour_tip->get('id') == 'tour-code-test-1') {
$tour_tip->set('body', 'Altered by hook_tour_tips_alter');
}
}
}

View File

@@ -0,0 +1,30 @@
tour_test.1:
path: '/tour-test-1'
defaults:
_controller: '\Drupal\tour_test\Controller\TourTestController::tourTest1'
options:
_admin_route: TRUE
requirements:
_access: 'TRUE'
tour_test.1_action:
path: '/tour-test-1/action'
defaults:
_controller: '\Drupal\tour_test\Controller\TourTestController::tourTest1'
requirements:
_access: 'TRUE'
tour_test.2:
path: '/tour-test-2/subpath'
defaults:
_controller: '\Drupal\tour_test\Controller\TourTestController::tourTest2'
requirements:
_access: 'TRUE'
tour_test.3:
path: '/tour-test-3/{locale}'
defaults:
locale: 'foo'
_controller: '\Drupal\tour_test\Controller\TourTestController::tourTest1'
requirements:
_access: 'TRUE'

61
core/modules/tour/tour.api.php Executable file
View File

@@ -0,0 +1,61 @@
<?php
/**
* @file
* Describes API functions for tour module.
*/
/**
* @defgroup help_docs Help and documentation
* @{
* Documenting modules, themes, and install profiles
*
* @section sec_tour Tours
* Modules can provide tours of administrative pages by creating tour config
* files and placing them in their config/optional subdirectory. See
* @link https://www.drupal.org/docs/8/api/tour-api/overview Tour API overview @endlink
* for more information. The contributed
* @link https://www.drupal.org/project/tour_ui Tour UI module @endlink
* can also be used to create tour config files.
* @}
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Allow modules to alter tour items before render.
*
* @param array $tour_tips
* Array of \Drupal\tour\TipPluginInterface items.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The tour which contains the $tour_tips.
*/
function hook_tour_tips_alter(array &$tour_tips, \Drupal\Core\Entity\EntityInterface $entity) {
foreach ($tour_tips as $tour_tip) {
if ($tour_tip->get('id') == 'tour-code-test-1') {
$tour_tip->set('body', 'Altered by hook_tour_tips_alter');
}
}
}
/**
* Allow modules to alter tip plugin definitions.
*
* @param array $info
* The array of tip plugin definitions, keyed by plugin ID.
*
* @see \Drupal\tour\Annotation\Tip
*/
function hook_tour_tips_info_alter(&$info) {
// Swap out the class used for this tip plugin.
if (isset($info['text'])) {
$info['class'] = 'Drupal\my_module\Plugin\tour\tip\MyCustomTipPlugin';
}
}
/**
* @} End of "addtogroup hooks".
*/

12
core/modules/tour/tour.info.yml Executable file
View File

@@ -0,0 +1,12 @@
name: Tour
type: module
description: 'Displays guided tours of the site interface.'
package: Core
lifecycle: deprecated
lifecycle_link: https://www.drupal.org/docs/core-modules-and-themes/deprecated-and-obsolete-modules-and-themes#s-tour
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,18 @@
tour:
version: VERSION
js:
js/tour.js: {}
dependencies:
- core/jquery
- core/once
- core/drupal
# @todo Remove this in https://www.drupal.org/project/drupal/issues/3204011
- core/internal.backbone
- core/internal.shepherd
- tour/tour-styling
tour-styling:
version: VERSION
css:
component:
css/tour.module.css: { media: screen }

117
core/modules/tour/tour.module Executable file
View File

@@ -0,0 +1,117 @@
<?php
/**
* @file
* Main functions of the module.
*/
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\tour\Entity\Tour;
/**
* Implements hook_help().
*/
function tour_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.tour':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t("The Tour module provides users with guided tours of the site interface. Each tour consists of several tips that highlight elements of the user interface, guide the user through a workflow, or explain key concepts of the website. For more information, see the <a href=':tour'>online documentation for the Tour module</a>.", [':tour' => 'https://www.drupal.org/documentation/modules/tour']) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Viewing tours') . '</dt>';
$output .= '<dd>' . t("If a tour is available on a page, a <em>Tour</em> button will be visible in the toolbar. If you click this button the first tip of the tour will appear. The tour continues after clicking the <em>Next</em> button in the tip. To see a tour users must have the permission <em>Access tour</em> and JavaScript must be enabled in the browser") . '</dd>';
$output .= '<dt>' . t('Creating tours') . '</dt>';
$output .= '<dd>' . t("Tours can be written as YAML-documents with a text editor, or using the contributed <a href=':tour_ui'>Tour UI</a> module. For more information, see <a href=':doc_url'>the online documentation for writing tours</a>.", [':doc_url' => 'https://www.drupal.org/developing/api/tour', ':tour_ui' => 'https://www.drupal.org/project/tour_ui']) . '</dd>';
$output .= '</dl>';
return $output;
}
}
/**
* Implements hook_toolbar().
*/
function tour_toolbar() {
$items = [];
$items['tour'] = [
'#cache' => [
'contexts' => [
'user.permissions',
],
],
];
if (!\Drupal::currentUser()->hasPermission('access tour')) {
return $items;
}
$items['tour'] += [
'#type' => 'toolbar_item',
'tab' => [
'#type' => 'html_tag',
'#tag' => 'button',
'#value' => t('Tour'),
'#attributes' => [
'class' => ['toolbar-icon', 'toolbar-icon-help'],
'aria-pressed' => 'false',
'type' => 'button',
],
],
'#wrapper_attributes' => [
'class' => ['tour-toolbar-tab', 'hidden'],
'id' => 'toolbar-tab-tour',
],
'#attached' => [
'library' => [
'tour/tour',
],
],
];
return $items;
}
/**
* Implements hook_page_bottom().
*/
function tour_page_bottom(array &$page_bottom) {
if (!\Drupal::currentUser()->hasPermission('access tour')) {
return;
}
// Load all of the items and match on route name.
$route_match = \Drupal::routeMatch();
$route_name = $route_match->getRouteName();
$results = \Drupal::entityQuery('tour')
->condition('routes.*.route_name', $route_name)
->condition('status', TRUE)
->execute();
if (!empty($results) && $tours = Tour::loadMultiple(array_keys($results))) {
foreach ($tours as $id => $tour) {
// Match on params.
if (!$tour->hasMatchingRoute($route_name, $route_match->getRawParameters()->all())) {
unset($tours[$id]);
}
}
if (!empty($tours)) {
$page_bottom['tour'] = \Drupal::entityTypeManager()
->getViewBuilder('tour')
->viewMultiple($tours, 'full');
}
}
}
/**
* Implements hook_ENTITY_TYPE_insert() for tour entities.
*/
function tour_tour_insert($entity) {
\Drupal::service('plugin.manager.tour.tip')->clearCachedDefinitions();
}
/**
* Implements hook_ENTITY_TYPE_update() for tour entities.
*/
function tour_tour_update($entity) {
\Drupal::service('plugin.manager.tour.tip')->clearCachedDefinitions();
}

View File

@@ -0,0 +1,2 @@
access tour:
title: 'Access tours'

View File

@@ -0,0 +1,4 @@
services:
plugin.manager.tour.tip:
class: Drupal\tour\TipPluginManager
parent: default_plugin_manager