component_library-1.0.x-dev/src/Form/ComponentOverrideForm.php
src/Form/ComponentOverrideForm.php
<?php
declare(strict_types=1);
namespace Drupal\component_library\Form;
use Drupal\component_library\ComponentOverrideManager;
use Drupal\component_library\Entity\ComponentOverride;
use Drupal\component_library\Event\PrepareOverrideEvent;
use Drupal\component_library\OverrideMode;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\HtmlCommand;
use Drupal\Core\Ajax\PrependCommand;
use Drupal\Core\Ajax\RedirectCommand;
use Drupal\Core\Ajax\RemoveCommand;
use Drupal\Core\Asset\LibraryDiscoveryInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Extension\ExtensionList;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ProfileExtensionList;
use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Template\TwigEnvironment;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Twig\Error\Error;
/**
* Component override form.
*/
final class ComponentOverrideForm extends EntityForm {
use ComponentOverrideFormTrait;
/**
* Cached ID for libraries gathered from all available/installed extensions.
*/
public const COLLECTED_LIBRARIES_CID = 'component_library:component_override_form:collected_libraries';
/**
* Theme.
*
* @var string
* The theme this Component Override applies to.
*/
protected string $theme;
protected TwigEnvironment $twig;
protected PrivateTempStore $cache;
protected OverrideMode $overrideMode;
protected RendererInterface $renderer;
protected ThemeHandlerInterface $themeHandler;
protected CacheBackendInterface $cacheDefault;
protected ExtensionList $profileExtensionList;
protected EventDispatcherInterface $dispatcher;
protected ThemeExtensionList $themeExtensionList;
private PrepareOverrideEvent $prepareOverrideEvent;
protected ModuleExtensionList $moduleExtensionList;
protected ComponentOverrideManager $overrideManager;
protected LibraryDiscoveryInterface $libraryDiscovery;
public function __construct(ThemeHandlerInterface $theme_handler, CacheBackendInterface $cache_default, ComponentOverrideManager $override_manager, LibraryDiscoveryInterface $library_discovery, ModuleExtensionList $module_list, ProfileExtensionList $profile_list, ThemeExtensionList $theme_list, TwigEnvironment $twig, EventDispatcherInterface $event_dispatcher, RendererInterface $renderer, PrivateTempStoreFactory $temp_store, OverrideMode $override_mode, RequestStack $request_stack) {
$this->twig = $twig;
$this->renderer = $renderer;
$this->overrideMode = $override_mode;
$this->themeHandler = $theme_handler;
$this->cacheDefault = $cache_default;
$this->dispatcher = $event_dispatcher;
$this->themeExtensionList = $theme_list;
$this->moduleExtensionList = $module_list;
$this->overrideManager = $override_manager;
$this->libraryDiscovery = $library_discovery;
$this->profileExtensionList = $profile_list;
$this->cache = $temp_store->get('component_overrides');
$this->requestStack = $request_stack;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): self {
return new self(
$container->get('theme_handler'),
$container->get('cache.default'),
$container->get('plugin.manager.component_override'),
$container->get('library.discovery'),
$container->get('extension.list.module'),
$container->get('extension.list.profile'),
$container->get('extension.list.theme'),
$container->get('twig'),
$container->get('event_dispatcher'),
$container->get('renderer'),
$container->get('tempstore.private'),
$container->get('component_library.override_mode'),
$container->get('request_stack')
);
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, $theme = '') {
$form_state->set('workspace_safe', TRUE);
// Ensure $theme is a valid theme.
if ($theme) {
if (!$this->themeHandler->hasUi($theme)) {
throw new NotFoundHttpException();
}
$this->entity->set('theme', $theme);
$this->theme = $theme;
}
elseif ($this->getFormId() === 'component_override_edit_form') {
$this->theme = $this->getEntity()->get('theme');
}
$this->prepareOverrideEvent = $this->dispatcher->dispatch(new PrepareOverrideEvent());
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state, $theme = NULL): array {
$form = parent::form($form, $form_state);
$form['configuration_container'] = [
'#type' => 'container',
];
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#maxlength' => 255,
'#default_value' => $this->entity->label(),
'#required' => TRUE,
'#group' => 'configuration_container',
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $this->entity->id(),
'#machine_name' => [
'exists' => \sprintf('%s::load', ComponentOverride::class),
],
'#disabled' => !$this->entity->isNew(),
'#group' => 'configuration_container',
];
$plugin_options = $this->overrideManager->getPluginOptions();
$plugin = $this->getPlugin($form_state);
if ($plugin === NULL) {
unset($plugin_options['override_mode']);
}
$form['plugin_container'] = [
'#type' => 'container',
'#prefix' => '<div id="override-plugin-container">',
'#suffix' => '</div>',
'#group' => 'configuration_container',
'#parents' => ['plugin_container'],
'#tree' => TRUE,
'#attributes' => ['class' => ['flex']],
'plugin' => [
'#title' => $this->t('Plugin'),
'#type' => 'select',
'#empty_option' => $this->t('- Select -'),
'#empty_value' => '',
'#options' => $plugin_options,
'#required' => TRUE,
'#default_value' => $plugin,
'#disabled' => !$this->getEntity()->isNew(),
'#ajax' => [
'callback' => [$this, 'loadPluginForm'],
'wrapper' => 'override-plugin-container',
'effect' => 'fade',
],
],
];
if (!$form_state->getValue('plugin_container')) {
// Give plugins a place to stash their values on initial load.
$form_state->setValue('plugin_container', []);
}
/** @var \Drupal\component_library\Plugin\ComponentOverride\ComponentOverrideInterface $override_plugin */
$override_plugin = NULL;
if ($plugin) {
$override_plugin = $this->entity->getPlugin($this->prepareOverrideEvent);
$form['#parents'] = [];
$subform_state = SubformState::createForSubform($form['plugin_container'], $form, $form_state);
$form['plugin_container'] += $override_plugin->buildConfigurationForm([], $subform_state);
// Populate the template when an override is initially selected.
if (\array_key_exists('override', $form['plugin_container'])) {
$form['plugin_container']['override']['#ajax'] = [
'callback' => [$this, 'loadDefaultTemplate'],
'wrapper' => 'dynamic-elements-container',
'effect' => 'fade',
];
}
}
$override = $this->getOverride($form_state);
$template = $form_state->getValue('template') ?? $this->entity->get('template') ?? '';
$trigger = $form_state->getTriggeringElement();
$input = $form_state->getUserInput();
if (
// The override field was selected.
(\is_array($trigger) && \array_key_exists('#name', $trigger) && $trigger['#name'] === 'plugin_container[override]') ||
// Override Mode passed in the override.
((empty($input['template']) && $this->prepareOverrideEvent->isPrepopulated())
&& $override)
) {
$template = $this->getTemplate($override, TRUE);
$input['template'] = $template;
$form_state->setUserInput($input);
}
$form['dynamic_elements_container'] = [
'#type' => 'container',
'#tree' => FALSE,
'#prefix' => '<div id="dynamic-elements-container">',
'#suffix' => '</div>',
];
$form['dynamic_elements_container']['template'] = [
'#type' => 'codemirror',
'#codemirror' => [
'autoCloseTags' => TRUE,
'buttons' => [
'undo',
'redo',
'enlarge',
'shrink',
],
'foldGutter' => TRUE,
'lineNumbers' => TRUE,
'lineWrapping' => TRUE,
'styleActiveLine' => TRUE,
'toolbar' => TRUE,
'lineSeparator' => "\n",
'mode' => 'html_twig',
'viewportMargin' => 'Infinity',
],
'#title' => $this->t('Template'),
'#default_value' => $template,
'#element_validate' => [[$this, 'twigValidate']],
'#suffix' => '<output id="override-preview"></output>',
];
if ($override && empty($form_state->get('selected_libraries')[$override])) {
$selected_libraries = [];
$selected_libraries[$override] = $form_state->getValue('selected_libraries') ?? $this->entity->get('libraries') ?? [];
$selected_libraries[$override] += $this->parseAttachedLibraries(
$this->getTemplate($override, FALSE)
);
$form_state->set('selected_libraries', $selected_libraries);
}
$attached_libraries = NULL;
if ($override) {
$attached_libraries = $form_state->get('selected_libraries')[$override];
$available_libraries = $this->getAvailableLibraries();
$form['dynamic_elements_container']['libraries_wrapper'] = [
'#type' => 'details',
'#prefix' => '<div id="libraries-wrapper">',
'#suffix' => '</div>',
'#open' => FALSE,
'#title' => $this->t('Libraries'),
'libraries_selector' => [
'#type' => 'select',
'#title' => $this->t('Add library'),
'#empty_option' => $this->t('- Select -'),
'#options' => $available_libraries,
'#ajax' => [
'callback' => [$this, 'ajaxUpdateLibraries'],
'wrapper' => 'libraries-wrapper',
'effect' => 'fade',
],
],
'libraries_list' => [
'#prefix' => '<h6>' . $this->t('Attached libraries') . '</h6>',
'#type' => 'table',
'#header' => [$this->t('Library'), $this->t('Remove')],
'#empty' => $this->t('No libraries are attached.'),
],
'libraries' => [
'#type' => 'value',
'#value' => $attached_libraries ?? [],
],
];
if ($attached_libraries) {
$this->buildLibrariesList($attached_libraries, $form);
}
}
$form['#prefix'] = '<div id="status-messages"></div>';
$form['#attached']['library'][] = 'component_library/component_override';
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state): void {
// Validate attached libraries.
$override = $form_state->getValue('override') ?? $this->entity->get('override');
$libraries = $form_state->get('selected_libraries');
$trigger = $form_state->getTriggeringElement();
if ((isset($trigger['#name']) && $trigger['#name'] === 'override') && $override) {
$theme_registry = $this->overrideManager->getThemeRegistry()[$form_state->getValue('override')];
$template_path = \sprintf('%s/%s.html.twig', $theme_registry['path'], $theme_registry['template']);
$template = \file_get_contents($template_path);
$libraries[$override] ??= $this->parseAttachedLibraries($template);
}
if ((isset($trigger['#name']) && $trigger['#name'] === 'libraries_selector')) {
$added_library = $form_state->getValue('libraries_selector');
if (!empty($added_library) && !\in_array($added_library, $libraries, TRUE)) {
$libraries[$override][] = $added_library;
}
}
if (isset($trigger['#parents'][1]) && isset($trigger['#name']) && \strpos($trigger['#name'], 'library_remove') !== FALSE) {
\array_splice($libraries[$override], $trigger['#parents'][1], 1);
}
$form_state->set('selected_libraries', $libraries);
// Validate the plugin.
$plugin_values = $form_state->getValue('plugin_container');
if (!empty($plugin_values['plugin'])) {
$override = $this->entity->getPlugin();
$form_state->setTemporaryValue('override_plugin', $override);
$subform_state = SubformState::createForSubform($form['plugin_container'], $form, $form_state);
$override->validateConfigurationForm($form, $subform_state);
}
}
/**
* Use element validator to make sure that Twig is valid.
*/
public function twigValidate(array $element, FormStateInterface $form_state): void {
$template = $form_state->getValue($element['#parents']);
$context = $this->getVariables();
$markup = [
'#type' => 'inline_template',
'#template' => $template,
'#context' => $context,
];
try {
$this->renderer->render($markup);
}
catch (Error $exception) {
$form_state->setError($element, new TranslatableMarkup('Twig error: %error', ['%error' => $exception->getMessage()]));
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
if ($override = $form_state->getTemporaryValue('override_plugin')) {
$subform_state = SubformState::createForSubform($form['plugin_container'], $form, $form_state);
$override->submitConfigurationForm($form, $subform_state);
}
parent::submitForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state): int {
$result = parent::save($form, $form_state);
$message_args = [
'@url' => Url::fromRoute(
'system.theme_settings_theme',
['theme' => $this->entity->get('theme')],
)->toString(),
'%label' => $this->entity->label(),
];
$message = $result == \SAVED_NEW
? $this->t('Created new %label <a href="@url">component override</a>.', $message_args)
: $this->t('Updated %label <a href="@url">component override</a>.', $message_args);
$this->messenger()->addStatus($message);
$this->cache->delete('variables');
return $result;
}
/**
* {@inheritdoc}
*/
protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state): void {
$value = \implode("\n", \preg_split('(\r\n|\r)', $form_state->getValue('template')));
$form_state->setValue('template', $value);
$plugin_values = $form_state->getValue('plugin_container');
if (isset($plugin_values['plugin'])) {
$entity->set('plugin', $plugin_values['plugin']);
}
if (isset($plugin_values['override'])) {
$entity->set('override', $plugin_values['override']);
}
$variables = $entity->getVariables();
if ($variables === NULL) {
$variables = $this->cache->get('variables');
$entity->set('variables', \serialize($variables));
}
parent::copyFormValuesToEntity($entity, $form, $form_state);
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions = parent::actions($form, $form_state);
$actions['submit']['#ajax'] = [
'callback' => [$this, 'ajaxSubmit'],
'event' => 'click',
];
return $actions;
}
/**
* AJAX callback to load the plugin form.
*/
public function loadPluginForm(array $form, FormStateInterface $form_state): array {
return $form['plugin_container'];
}
/**
* AJAX callback to load the plugin form.
*/
public function loadDefaultTemplate(array $form, FormStateInterface $form_state): array {
return $form['dynamic_elements_container'];
}
/**
* AJAX callback to submit the override form.
*/
public function ajaxSubmit(array $form, FormStateInterface $form_state): AjaxResponse {
if ($error_messages = $this->checkForErrorMessages()) {
return $error_messages;
}
$response = new AjaxResponse();
$url = $this->getRedirectDestination()->get();
$query = $this->getRequest()->query->all();
if ($query && !array_key_exists('destination', $query)) {
$url = Url::fromRoute(
'system.theme_settings_theme',
['theme' => $this->entity->get('theme')],
)->toString();
}
$response->addCommand(new RedirectCommand($url));
return $response;
}
/**
* Ajax callback to preview the template.
*/
public function previewCallback(array $form, FormStateInterface $form_state): AjaxResponse {
if ($error_messages = $this->checkForErrorMessages()) {
return $error_messages;
}
$markup = [
'#type' => 'container',
'#attributes' => ['id' => 'override-preview-context'],
'label' => [
'#markup' => $this->t('<h2>Preview @label</h2>', [
'@label' => $form_state->getValue('plugin_container')['override'] ?? '',
]),
],
'preview' => [
'#type' => 'inline_template',
'#template' => $form_state->getValue('template'),
'#context' => $this->getVariables(),
],
];
try {
$rendered = $this->renderer->render($markup);
}
catch (Error $exception) {
$rendered = new TranslatableMarkup('Twig error: %error', ['%error' => $exception->getMessage()]);
}
$response = new AjaxResponse();
$response->addCommand(new RemoveCommand('[data-drupal-messages]'));
$response->addCommand(new HtmlCommand('#override-preview', $rendered));
return $response;
}
/**
* Check for error messages.
*
* @return \Drupal\Core\Ajax\AjaxResponse|null
* Renders the status messages.
*/
private function checkForErrorMessages(): ?AjaxResponse {
$all_messages = $this->messenger()->all();
if (!empty($all_messages['error'])) {
$response = new AjaxResponse();
$status_messages = ['#type' => 'status_messages'];
$messages = $this->renderer->renderRoot($status_messages);
if (!empty($messages)) {
$response->addCommand(new RemoveCommand('[data-drupal-messages]'));
$response->addCommand(new PrependCommand('#status-messages', $messages));
}
return $response;
}
return NULL;
}
/**
* AJAX callback to load the libraries list.
*/
public function ajaxUpdateLibraries(array &$form, FormStateInterface $form_state): array {
return $form['dynamic_elements_container']['libraries_wrapper'];
}
/**
* Builds the rows of the libraries list widget.
*
* @param array $selected_libraries
* The currently selected libraries.
* @param array $form
* The form array.
*/
private function buildLibrariesList(array $selected_libraries, array &$form): void {
$row = 0;
foreach ($selected_libraries as $key => $library) {
$form['dynamic_elements_container']['libraries_wrapper']['libraries_list'][$row]['library'] = [
'#markup' => $library,
];
$button_key = 'library_remove' . $key;
$form['dynamic_elements_container']['libraries_wrapper']['libraries_list'][$row][$button_key] = [
'#type' => 'submit',
'#value' => $this->t('Remove'),
'#name' => $button_key,
'#limit_validation_errors' => TRUE,
'#executes_submit_callback' => FALSE,
'#ajax' => [
'callback' => [$this, 'ajaxUpdateLibraries'],
'wrapper' => 'libraries-wrapper',
'effect' => 'fade',
],
];
$row++;
}
}
/**
* Parses libraries from a given template string.
*
* @param string $template
* The template to parse from.
*
* @return array
* List of libraries.
*/
private function parseAttachedLibraries($template): array {
$matches = [];
$libraries = [];
\preg_match_all('/attach_library\(.+\)/', $template, $matches);
if (!empty($matches[0])) {
foreach ($matches[0] as $attach_statement) {
$libraries[] = \mb_substr(
$attach_statement,
\mb_strlen("attach_library('"),
\mb_strlen($attach_statement) - \mb_strlen("attach_library('") - \mb_strlen("')")
);
}
}
return $libraries;
}
/**
* Gathers libraries from core, profiles, installed modules and themes.
*
* @return array
* Nested array of libraries in the form of extension => [libraries].
*/
private function getAvailableLibraries(): array {
$cache = $this->cacheDefault->get(self::COLLECTED_LIBRARIES_CID);
if (!empty($cache->data)) {
return $cache->data;
}
else {
$available_libraries = [];
$modules = $this->getInstalledList($this->moduleExtensionList);
$profiles = $this->getInstalledList($this->profileExtensionList);
$themes = $this->getInstalledList($this->themeExtensionList);
$core_libraries = $this->libraryDiscovery->getLibrariesByExtension('core');
foreach ($core_libraries as $library_name => $library) {
$available_libraries['core']['core/' . $library_name] = 'core/' . $library_name;
}
/** @var \Drupal\Core\Extension\Extension $extension */
foreach (\array_merge($modules, $profiles, $themes) as $extension_name => $extension) {
$extension_info = (array) $extension;
if ($extension->getType() == 'profile'|| !empty($extension_info['status'])) {
$extension_libraries = $this->libraryDiscovery->getLibrariesByExtension($extension_name);
$libraries_list = [];
foreach ($extension_libraries as $library_name => $library) {
$libraries_list[$extension_name][$extension_name . '/' . $library_name] = $extension_name . '/' . $library_name;
}
$available_libraries = \array_merge($available_libraries, $libraries_list);
}
}
$component_library_libraries = $available_libraries['component_library'];
// Move Component Libraries to the front of the line.
unset($available_libraries['component_library']);
$available_libraries = ['component_library' => $component_library_libraries] + $available_libraries;
$this->cacheDefault->set(
self::COLLECTED_LIBRARIES_CID,
$available_libraries,
Cache::PERMANENT,
[
'config:core.extension',
'library_info',
'component_library_asset_list',
]
);
return $available_libraries;
}
}
/**
* Get installed list.
*
* @param \Drupal\Core\Extension\ExtensionList $extension_list
* An extension list.
*
* @return array
* An array of installed extensions for the given list.
*/
private function getInstalledList(ExtensionList $extension_list): array {
$list = [];
foreach ($extension_list->getAllInstalledInfo() as $name => $info) {
$list[$name] = $extension_list->get($name);
}
return $list;
}
/**
* Get template.
*
* Gets the most relevant template as a starting point for users to alter.
*
* @param string $override
* The theme suggestion being overridden.
* @param bool $strip_libraries
* Whether to strip attach_library twig statements from the template.
*
* @return string
* The file contents of the template.
*
* @throws \Twig\Error\LoaderError
*/
private function getTemplate(string $override, $strip_libraries): string {
$registry = $this->overrideManager->getThemeRegistry();
$pieces = \explode('__', $override);
for ($i = \count($pieces); $i > 0; $i--) {
$possible_theme_suggestion = \implode('__', \array_slice($pieces, 0, $i));
if (!empty($registry[$possible_theme_suggestion])) {
$path = \sprintf('%s/%s.html.twig', $registry[$possible_theme_suggestion]['path'], $registry[$possible_theme_suggestion]['template']);
$template = $this->twig->getLoader()->getSourceContext($path)->getCode();
if ($strip_libraries) {
// Strip attach_library statements as libraries get displayed in
// dedicated field.
$template = \preg_replace('/\{\{ attach_library\(.+\) \}\}[\r\n|\r]/', '', $template);
$template = \preg_replace('/\{\{attach_library\(.+\)\}\}[\r\n|\r]/', '', $template);
}
return $template;
}
}
return '';
}
/**
* Get variables.
*
* @return array
* The preprocessed variables.
*/
private function getVariables(): array {
$variables = $this->getEntity()->getVariables() ?? $this->cache->get('variables') ?? [];
$variables = $this->overrideMode->replacePlaceholders($variables);
return $variables;
}
/**
* Get plugin.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return string|null
* The plugin ID.
*/
private function getPlugin(FormStateInterface $form_state): ?string {
$plugin = $this->getPluginContainerValue('plugin', $form_state);
if ($plugin === NULL) {
$plugin = $this->getValue('plugin', $this->getEntity(), $form_state);
}
if ($plugin) {
$this->entity->set('plugin', $plugin);
$this->prepareOverrideEvent->setPlugin($plugin);
}
return $plugin;
}
/**
* Get override.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return string|null
* The override.
*/
private function getOverride(FormStateInterface $form_state): ?string {
$override = $this->getPluginContainerValue('override', $form_state);
if ($override === NULL) {
$override = $this->getValue('override', $this->getEntity(), $form_state);
}
return $override;
}
/**
* Get plugin container value.
*
* @param string $form_item
* The nested form item key to retrieve.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return string|null
* The value.
*/
private function getPluginContainerValue(string $form_item, FormStateInterface $form_state): ?string {
$value = NULL;
$plugin_container = $form_state->getValue('plugin_container');
if ($plugin_container && !empty($plugin_container[$form_item])) {
$value = $plugin_container[$form_item];
}
return $value;
}
}
