bootstrap_five_layouts-1.0.x-dev/src/Plugin/Layout/BootstrapFiveLayoutsBase.php
src/Plugin/Layout/BootstrapFiveLayoutsBase.php
<?php
namespace Drupal\bootstrap_five_layouts\Plugin\Layout;
use Drupal\bootstrap_five_layouts\Traits\BytesFormatTrait;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\Layout\LayoutDefault;
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\bootstrap_five_layouts\BootstrapFiveLayoutsManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
/**
* Layout class for all bootstrap layouts.
*/
abstract class BootstrapFiveLayoutsBase extends LayoutDefault implements PluginFormInterface, ContainerFactoryPluginInterface {
use BytesFormatTrait;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The module handler service.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The Bootstrap Layouts manager service.
*
* @var \Drupal\bootstrap_five_layouts\BootstrapFiveLayoutsManager
*/
protected $bootstrapLayoutsManager;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler, BootstrapFiveLayoutsManager $bootstrap_five_layouts_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->configFactory = $config_factory;
$this->moduleHandler = $module_handler;
$this->bootstrapLayoutsManager = $bootstrap_five_layouts_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('config.factory'),
$container->get('module_handler'),
$container->get('plugin.manager.bootstrap_five_layouts')
);
}
/**
* Provides a default Container definition.
*
* @return array
* Default region array.
*/
protected function getContainerDefaults() {
return [
'container_type' => 'no-container',
'classes' => '',
'container_visibility' => '',
'container_theme' => '',
'backgound_image' => NULL,
];
}
/**
* Provides a default region definition.
*
* @return array
* Default region array.
*/
protected function getRegionDefaults() {
return [
'wrapper' => 'div',
'classes' => [],
'flex' => [],
'offset' => [],
'visibility' => [],
'flex-shrink' => [],
'flex-grow' => [],
'theme' => '',
'custom' => '',
'attributes' => '',
'id' => '',
'add_region_classes' => TRUE,
'add_base_col' => TRUE,
];
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
$configuration = parent::defaultConfiguration();
$configuration += [
'container' => [
'container_type' => 'no-container',
'classes' => '',
'container_theme' => '',
'container_visibility' => '',
'background_image' => 0,
],
'row' => [
'wrapper' => 'div',
'columns' => [],
'theme' => '',
'custom' => '',
'classes_margin' => [],
'classes_margin_x' => [],
'classes_margin_y' => ['my-2'],
'classes_margin_top' => [],
'classes_margin_end' => [],
'classes_margin_bottom' => [],
'classes_margin_start' => [],
'classes_padding' => [],
'classes_padding_x' => [],
'classes_padding_y' => ['py-2'],
'classes_padding_top' => [],
'classes_padding_end' => [],
'classes_padding_bottom' => [],
'classes_padding_start' => [],
'classes_text_align' => [],
'alignment' => [],
'align_self' => [],
'attributes' => '',
'id' => '',
'add_row_classes' => TRUE,
],
'regions' => [],
];
foreach ($this->getPluginDefinition()->getRegions() as $region => $info) {
$region_configuration = [];
foreach (['wrapper', 'classes', 'attributes'] as $key) {
if (isset($info[$key])) {
$region_configuration[$key] = $info[$key];
}
}
$configuration['regions'][$region] = $region_configuration + $this->getRegionDefaults();
}
return $configuration;
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
// Ensure core attribute/label is included.
$form = parent::buildConfigurationForm($form, $form_state);
// This can potentially be invoked within a subform instead of a normal
// form. There is an ongoing discussion around this which could result in
// the passed form state going back to a full form state. In order to
// prevent BC breaks, check which type of FormStateInterface has been
// passed and act accordingly.
// @see https://www.drupal.org/node/2868254
// @todo Re-evaluate once https://www.drupal.org/node/2798261 makes it in.
$complete_form_state = $form_state instanceof SubformStateInterface ? $form_state->getCompleteFormState() : $form_state;
$configuration = $this->getConfiguration();
/** @var \Drupal\bootstrap_five_layouts\BootstrapFiveLayoutsManager $manager */
$manager = $this->bootstrapLayoutsManager;
$theme_options = $manager->getThemeOptions();
$has_theme_options = count($theme_options)>1 ? TRUE : FALSE;
$primaryOptions = $manager->getPrimaryOptions();
// preflight helpful descriptions
$id_description = '<ul>
<li>' . $this->t("Use lower-case, class safe phrase. You can use dashes (yes). NO: underscore, spaces, special characters allowed."). '</li>
<li>' . $this->t("Ensure ID's entered unique across sections on same layout page."). '</li>
<li>' . $this->t('Can be used for page hash linking') . '</li>
</ul>';
$attr_description = '<ul>
<li>' . $this->t('E.g. role|navigation,data-bs-something|some value'). '</li>
<li>' . $this->t('Do not enter Class or ID here.') . '</li>
</ul>';
$pills_description = '<ul class="pills-description"><li>'.$this->t('Seperate by spaces.').'</li>'.
'<li>'.$this->t('Do not start names with period (.) and should start with a letter.').'</li>'.
'</ul>';
// Token Integration
$tokens = FALSE;
if ($this->moduleHandler->moduleExists('token')) {
$tokens = [
'#title' => $this->t('Tokens'),
'#type' => 'container',
];
$tokens['help'] = [
'#theme' => 'token_tree_link',
'#token_types' => 'all',
'#global_types' => FALSE,
'#dialog' => TRUE,
];
}
// Wrapper Options.
$wrapper_options = [
'div' => 'Div',
'span' => 'Span',
'section' => 'Section',
'article' => 'Article',
'header' => 'Header',
'footer' => 'Footer',
'aside' => 'Aside',
'figure' => 'Figure',
];
// Expose classlist map to JS via drupalSettings. (setups cache)
$classlist_map = $manager->getAllKnownClassnames();
$form['#attached']['drupalSettings']['bootstrap_five_layouts']['classlist_map'] = $classlist_map;
$pillbox_classes = $manager->getPillboxClasses();
$form['#attached']['drupalSettings']['bootstrap_five_layouts']['pillbox_classes'] = $pillbox_classes;
// use cache info with nesting.
$ulility_divisions = $manager->getUtilityDivisions();
// Expose description helper selector to JS via drupalSettings.
$config = $this->configFactory->get('bootstrap_five_layouts.settings');
$form['#attached']['drupalSettings']['bootstrap_five_layouts']['description_helper_selector'] = $config->get('description_helper_selector') ?: '.form-item__description';
// Add related JS/CSS tools.
$form['#attached']['library'][] = 'bootstrap_five_layouts/layout_admin';
$form['#attached']['library'][] = 'bootstrap_five_layouts/multiselect';
$form['#attached']['library'][] = 'bootstrap_five_layouts/descriptionHelper';
$form['#attached']['library'][] = 'bootstrap_five_layouts/pillbox';
$form['#attached']['library'][] = 'bootstrap_five_layouts/pillbox_counter';
// Add a wrapping close open for css.
$form['open-class'] = [
'#type' => '#markup',
'#markup' => '<div class="bootstrap-five-layouts-settings-tray-admin">',
'#weight' => -50,
];
$form['container'] = [
'#title' => $this->t('Container Options'),
'#type' => 'details',
'#tree' => TRUE,
'#open' => (bool) $this->configFactory->get('bootstrap_five_layouts.settings')->get('container_appearence'),
];
$form['container']['container_type'] = [
'#title' => $this->t('Container Type'),
'#type' => 'select',
'#tree' => TRUE,
'#options' => $manager->getContainerOptions(),
'#default_value' => $complete_form_state->getValue(['container', 'container_type'], $configuration['container']['container_type']),
'#weight' => -50,
];
$form['container']['classes'] = [
'#type' => 'textfield',
'#maxlength' => $this->configFactory->get('bootstrap_five_layouts.settings')->get('custom_maxlength'),
'#title' => $this->t('Container Custom Classes'),
'#description' => $pills_description,
'#default_value' => $complete_form_state->getValue(['container', 'classes'], $configuration['container']['classes']),
'#attributes' => [
'data-bsfl-pillbox' => 'true',
'data-pillbox-counter' => 'true',
],
'#weight' => -49
];
$form['container']['container_theme'] = [
'#access' => 'false',
'#type' => 'select',
'#title' => $this->t('Container Theme'),
'#access' => $has_theme_options,
'#options' => $theme_options,
'#default_value' => $complete_form_state->getValue(['container', 'container_theme'], $configuration['container']['container_theme']),
'#multiple' => FALSE,
'#weight' => -48
];
$form['container']['container_visibility'] = [
'#type' => 'select',
'#title' => $this->t('Container Visibility'),
'#options' => $primaryOptions['visibility']['field']['#options'],
'#default_value' => $complete_form_state->getValue(['container', 'container_visibility'], $configuration['container']['container_visibility']),
'#description_display' => 'after',
'#description' => $primaryOptions['visibility']['field']['#description'],
'#multiple' => TRUE,
'#size' => 12,
'#attributes' => [
'data-multiselect-enhanced' => 'true',
'data-multiselect-multiple' => 'true',
],
];
$enable_background = $this->configFactory->get('bootstrap_five_layouts.settings')->get('enable_background');
$form['container']['background'] = [
'#title' => $this->t('Background'),
'#type' => 'container',
'#access' => $enable_background
];
// $default = $complete_form_state->getValue(['container', 'background_image'], $configuration['container']['background_image']);
// $form['container']['background']['image'] = $this->chooseFileType($default, $folder);
// $form['container']['background']['image']['#access'] = false;
$col_ulility_divisions = $ulility_divisions['col'];
$this->addFormZoneInstances($form, $complete_form_state, $col_ulility_divisions, $this->t('Container'), 'container');
$form['row'] = [
'#title' => $this->t('Row Options'),
'#type' => 'details',
'#tree' => TRUE,
'#open' => (bool) $this->configFactory->get('bootstrap_five_layouts.settings')->get('row_appearence'),
];
$form['row']['theme'] = [
'#type' => 'select',
'#title' => $this->t('Row Theme'),
'#access' => $has_theme_options,
'#options' => $theme_options,
'#default_value' => $complete_form_state->getValue(['row', 'theme'], $configuration['row']['theme']),
'#multiple' => FALSE,
'#weight' => -50,
];
$form['row']['wrapper'] = [
'#type' => 'select',
'#title' => $this->t('Row Semantic/HTML Tag'),
'#options' => $wrapper_options,
'#default_value' => $complete_form_state->getValue(['row', 'wrapper'], $configuration['row']['wrapper']),
'#weight' => -49,
];
$form['row']['id'] = [
'#type' => 'textfield',
'#title' => $this->t('Row ID'),
'#description' => $id_description,
'#default_value' => $complete_form_state->getValue(['row', 'id'], $configuration['row']['id']),
'#weight' => -48,
];
$form['row']['custom'] = [
'#type' => 'textfield',
'#maxlength' => $this->configFactory->get('bootstrap_five_layouts.settings')->get('custom_maxlength'),
'#title' => $this->t('Row Custom Classes'),
'#default_value' => $complete_form_state->getValue(['row', 'custom'], $configuration['row']['custom']),
'#description' => $pills_description,
'#attributes' => [
'data-bsfl-pillbox' => 'true',
'data-pillbox-counter' => 'true',
],
'#weight' => -47,
];
// Reminder: row-cols is handled seperatly from other utility_options items due to it's singular context.
$base_options = $this->configFactory->get('bootstrap_five_layouts.settings')->get('base_options');
$row_cols_conf = $base_options['row-cols'] ?? [];
$row_cols_options = $manager->processUtilityOption($row_cols_conf);
$form['row']['columns'] = [
'#type' => 'select',
'#title' => $this->t('Row Column Options'),
'#options' =>$row_cols_options,
'#default_value' => $complete_form_state->getValue(['row', 'columns'], $configuration['row']['columns']),
'#multiple' => TRUE,
'#size' => 12,
'#attributes' => [
'data-multiselect-enhanced' => 'true',
'data-multiselect-single-group' => 'true',
],
'#weight' => -46,
];
$row_ulility_divisions = $ulility_divisions['row'];
$this->addFormZoneInstances($form, $complete_form_state, $row_ulility_divisions, $this->t('Row'), 'row');
$form['row']['advanced'] = [
'#group' => 'advanced',
'#type' => 'details',
'#tree' => TRUE,
'#open' => false,
'#title' => $this->t('Advanced Row'),
'#weight' => 20,
];
$form['row']['advanced']['add_row_classes'] = [
'#type' => 'checkbox',
'#title' => $this->t('Add row layout specific class: <code>@class</code>', ['@class' => Html::cleanCssIdentifier('row-'.$this->getPluginId())]),
'#default_value' => (int) $complete_form_state->getValue(['row', 'advanced', 'add_row_classes'], $configuration['row']['add_row_classes']),
];
$form['row']['advanced']['attributes'] = [
'#type' => 'textarea',
'#rows' => 2,
'#title' => $this->t('Additional Row Attributes'),
'#description' => $attr_description,
'#default_value' => $complete_form_state->getValue(['row', 'advanced', 'attributes'], $configuration['row']['attributes']),
];
if ($tokens) {
$form['row']['tokens'] = $tokens;
}
// Columns begin
$regions = $this->getPluginDefinition()->getRegions();
$column_count = is_countable($regions) ? count($regions) : 0;
$label = $this->t('Columns');
if ($column_count>0){
switch ($column_count) {
case 1:
$label = $this->t('1 Column – Full Width');
break;
case 2:
$label = $this->t('2 Columns – Split');
break;
case 3:
$label = $this->t('3 Columns – Balanced');
break;
case 4:
$label = $this->t('4 Columns – Grid');
break;
case 6:
$label = $this->t('6 Columns – Equal Width Grid');
break;
case 12:
$label = $this->t('12 Columns – Bootstrap Full Grid');
break;
default:
$label = $this->t('@count Columns – Custom Grid', ['@count' => $column_count]);
break;
}
$form['region-label'] = [
'#markup' => '<h3>' . $label . '</h3>'
];
}
// Add each region's settings.
foreach ($regions as $region => $region_info) {
$region_label = $region_info['label'];
$default_values = NestedArray::mergeDeep(
$this->getRegionDefaults(),
isset($configuration['regions'][$region]) ? $configuration['regions'][$region] : [],
$complete_form_state->getValue(['regions', $region], [])
);
// Single Column,
if ($region=='column'){
$title = $region_label;
} else {
$title = $this->t('Column: @region', ['@region' => $region_label]);
}
$form[$region] = [
'#group' => 'additional_settings',
'#type' => 'details',
'#tree' => TRUE,
'#open' => (bool) $this->configFactory->get('bootstrap_five_layouts.settings')->get('column_appearence'),
'#title' => $title,
'#weight' => 20,
];
$form[$region]['theme'] = [
'#type' => 'select',
'#title' => $this->t('<q>@region</q> Theme', ['@region' => $region_label]),
'#access' => $has_theme_options,
'#options' => $theme_options,
'#default_value' => $default_values['theme'] ?? [],
'#multiple' => FALSE,
'#weight' => -50,
];
$form[$region]['wrapper'] = [
'#type' => 'select',
'#title' => $this->t('<q>@region</q> Semantic/HTML Tag', ['@region' => $region_label]),
'#options' => $wrapper_options,
'#default_value' => $default_values['wrapper'],
'#weight' => -49,
];
$form[$region]['id'] = [
'#type' => 'textfield',
'#title' => $this->t('<q>@region</q> ID', ['@region' => $region_label]),
'#description' => $id_description,
'#default_value' => $default_values['id'] ?? '',
'#weight' => -48,
];
$pills_description = '<ul class="pills-description"><li>'.$this->t('Seperate by spaces.').'</li>'.
'<li>'.$this->t('Class names should start with a letter. Also, do not start names with period (.) and should start with a letter.').'</li></ul>';
$form[$region]['custom'] = [
'#type' => 'textfield',
'#maxlength' => $this->configFactory->get('bootstrap_five_layouts.settings')->get('custom_maxlength'),
'#title' => $this->t('<q>@region</q> Custom Classes', ['@region' => $region_label]),
'#default_value' => $default_values['custom'],
'#description' => $pills_description,
'#attributes' => [
'data-bsfl-pillbox' => 'true',
'data-pillbox-counter' => 'true',
],
'#weight' => -47,
];
$form[$region]['add_base_col'] = [
'#type' => 'checkbox',
'#title' => $this->t('Add <q>col</q> class for @region Column', ['@region' => $region_label]),
'#default_value' => (int) $default_values['add_base_col'],
'#description' => $this->t('Adds the basic Bootstrap column class when checked.'),
'#weight' => -46,
];
// Reminder: col handling seperatly from othe rutility_options items due to it's singular context.
$base_options = $this->configFactory->get('bootstrap_five_layouts.settings')->get('base_options');
$cols_conf = $base_options['col'] ?? [];
$col_options = $manager->processUtilityOption($cols_conf);
$form[$region]['classes'] = [
'#type' => 'select',
'#title' => $this->t('<q>@region</q> Responsive Columns', ['@region' => $region_label]),
'#options' => $col_options,
'#default_value' => $default_values['classes'],
'#multiple' => TRUE,
'#size' => 12,
'#attributes' => [
'data-multiselect-enhanced' => 'true',
'data-multiselect-single-group' => 'true',
'placeholder' => $this->t('Select responsive column details'),
],
'#weight' => -45,
];
$flexOptions = NestedArray::mergeDeep(
$primaryOptions['flex-shrink']['field']['#options'],
$primaryOptions['flex-grow']['field']['#options']
);
$desc = '<ul>'.
'<li>'.$primaryOptions['flex-grow']['field']['#description'].'</li>'.
'<li>'. $primaryOptions['flex-shrink']['field']['#description'].'</li>'
.'</ul>';
$form[$region]['flex'] = [
'#type' => 'select',
'#title' => $this->t('<q>@region</q> Column Flex (shrink/grow)', ['@region' => $region_label]),
'#options' => $flexOptions,
'#default_value' => $default_values['flex'],
'#description_display' => 'after',
'#description' => $desc,
'#multiple' => TRUE,
'#weight' => -44,
'#size' => 12,
'#attributes' => [
'data-multiselect-enhanced' => 'true',
'data-multiselect-single-group' => 'true',
],
];
$form[$region]['offset'] = [
'#type' => 'select',
'#title' => $this->t('<q>@region</q> Offset', ['@region' => $region_label]),
'#options' => $primaryOptions['offset']['field']['#options'],
'#default_value' => $default_values['offset'],
'#description_display' => 'after',
'#description' => $primaryOptions['offset']['field']['#description'],
'#multiple' => TRUE,
'#size' => 12,
'#attributes' => [
'data-multiselect-enhanced' => 'true',
'data-multiselect-single-group' => 'true',
],
];
$form[$region]['visibility'] = [
'#type' => 'select',
'#title' => $this->t('<q>@region</q> Visibility', ['@region' => $region_label]),
'#options' => $primaryOptions['visibility']['field']['#options'],
'#default_value' => $default_values['visibility'],
'#description_display' => 'after',
'#description' => $primaryOptions['offset']['field']['#description'],
'#multiple' => TRUE,
'#size' => 12,
'#attributes' => [
'data-multiselect-enhanced' => 'true',
'data-multiselect-multiple' => 'true',
],
];
$col_ulility_divisions = $ulility_divisions['col'];
$this->addFormColumnInstances($form, $default_values, $col_ulility_divisions, $region_label, $region);
$form[$region]['advanced'] = [
'#group' => 'advanced',
'#type' => 'details',
'#tree' => TRUE,
'#open' => false,
'#title' => $this->t('Advanced: @region', ['@region' => $region_label]),
'#weight' => 20,
];
$form[$region]['advanced']['add_region_classes'] = [
'#type' => 'checkbox',
'#title' => $this->t('Add region specific classes for @region: <code>@layout-layout</code> and <code>@layout-region-@region</code>', [
'@region' => Html::cleanCssIdentifier($region),
'@layout' => Html::cleanCssIdentifier(mb_strtolower($this->getPluginId())),
]),
'#default_value' => (int) $default_values['add_region_classes'],
];
$form[$region]['advanced']['attributes'] = [
'#type' => 'textarea',
'#rows' => 2,
'#title' => $this->t('Additional <q>@region</q> Attributes', ['@region' => $region_label]),
'#description' => $attr_description,
'#default_value' => $default_values['attributes'],
];
if ($tokens) {
$form[$region]['tokens'] = $tokens;
}
}
// Add a wrapping close tag for css
$form['close-class'] = [
'#type' => '#markup',
'#markup' => '</div">',
'#weight' => 50,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration) {
// Don't use NestedArray::mergeDeep here since this will merge both the
// default classes and the classes stored in config.
$default = $this->defaultConfiguration();
// Ensure top level properties exist.
$configuration += $default;
// Ensure specific top level sub-properties exists.
$configuration['container'] += $default['container'];
$configuration['row'] += $default['row'];
// For regions, merge properly to preserve preset classes from plugin definition.
$regions = $this->getPluginDefinition()->getRegions();
foreach (array_keys($configuration['regions']) as $region) {
if (isset($regions[$region])) {
// Merge existing region config with defaults to preserve preset classes.
$configuration['regions'][$region] = array_merge($default['regions'][$region], $configuration['regions'][$region]);
}
}
// Add any missing regions from defaults.
$configuration['regions'] += $default['regions'];
// Remove any region configuration that doesn't apply to current layout.
foreach (array_keys($configuration['regions']) as $region) {
if (!isset($regions[$region])) {
unset($configuration['regions'][$region]);
}
}
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
// Get form values for validation
$container_values = $form_state->getValue('container', []);
$layout_values = $form_state->getValue('row', []);
// Validate container settings
$this->validateContainerSettings($container_values, $form_state);
// Validate layout settings
$this->validateRowSettings($layout_values, $form_state);
// Validate region settings
foreach ($this->getPluginDefinition()->getRegionNames() as $region_name) {
$region_values = $form_state->getValue($region_name, []);
$this->validateRegionSettings($region_name, $region_values, $form_state);
}
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
$defaults = $this->getRegionDefaults();
if ($container_raw = $form_state->getValue('container', $defaults)) {
// Filter out detail-group keys (form organizational structure) from configuration
$container_valid_keys = ['container_type', 'classes', 'container_theme', 'container_visibility'];
$container = [];
foreach ($container_raw as $key => $value) {
// Keep valid config keys or utility class keys, skip detail-group structure
if (in_array($key, $container_valid_keys) || strpos($key, 'classes_') === 0) {
$container[$key] = $value;
}
}
// Extract utility class values from detail-group structure if present
foreach ($container_raw as $type => $type_values) {
if (in_array($type, $container_valid_keys)) {
continue;
}
if (!empty($type_values) && is_array($type_values)) {
foreach ($type_values as $item => $item_values) {
if (!empty($item_values) && is_array($item_values)) {
foreach ($item_values as $instance_name => $value) {
if (strpos($instance_name, 'classes_') === 0) {
$container[$instance_name] = $value;
}
}
}
}
}
}
$container['container_type'] = $form_state->getValue(['container', 'container_type'], []);
$container['build_classes'] = Html::cleanCssIdentifier($form_state->getValue(['container', 'build_classes'], ''));
// Apply Xss::filter to attributes.
$container['classes'] = Html::cleanCssIdentifier($form_state->getValue(['container', 'classes'], ''));
$container['container_theme'] = $form_state->getValue(['container', 'container_theme'], []);
$container['container_visibility'] = $form_state->getValue(['container', 'container_visibility'], []);
//start containter_divisions
//end divisions
////////////////
$this->configuration['container'] = $container;
}
if ($row_raw = $form_state->getValue('row', $defaults)) {
// Filter out detail-group keys (form organizational structure) from configuration
$row_valid_keys = ['add_row_classes', 'attributes', 'id', 'custom', 'offset', 'theme', 'visibility', 'advanced', 'gutter'];
$row = [];
foreach ($row_raw as $key => $value) {
// Keep valid config keys or utility class keys, skip detail-group structure
if (in_array($key, $row_valid_keys) || strpos($key, 'classes_') === 0) {
$row[$key] = $value;
}
}
// Extract utility class values from detail-group structure if present
foreach ($row_raw as $type => $type_values) {
if (in_array($type, $row_valid_keys)) {
continue;
}
if (!empty($type_values) && is_array($type_values)) {
foreach ($type_values as $item => $item_values) {
if (!empty($item_values) && is_array($item_values)) {
foreach ($item_values as $instance_name => $value) {
if (strpos($instance_name, 'classes_') === 0) {
$row[$instance_name] = $value;
}
}
}
}
}
}
$row['add_row_classes'] = $form_state->getValue(['row', 'advanced', 'add_row_classes'], 1);
// Apply Xss::filter to attributes.
$row_classes_value = $form_state->getValue(['row', 'classes'], '');
$row['columns'] = $form_state->getValue(['row', 'columns'], '');
$row_attributes_value = $form_state->getValue(['row', 'advanced', 'attributes'], '');
$row['attributes'] = Xss::filter(is_string($row_attributes_value) ? $row_attributes_value : '');
$row['id'] = Html::cleanCssIdentifier($form_state->getValue(['row', 'id'], ''));
$row['custom'] = Html::cleanCssIdentifier($form_state->getValue(['row', 'custom'], ''));
$row['offset'] = $form_state->getValue(['row', 'offset'], []);
$row['theme'] = $form_state->getValue(['row', 'theme'], []);
$row['visibility'] = $form_state->getValue(['row', 'visibility'], []);////////////////
//start rows_divisions
//end divisions
////////////////
dpm($row);
$this->configuration['row'] = $row;
}
$regions = [];
foreach ($this->getPluginDefinition()->getRegionNames() as $name) {
// Start with the existing configuration to preserve preset values from YAML.
$existing_config = $this->configuration['regions'][$name] ?? [];
$region_raw = $form_state->getValue($name, []);
// Filter out detail-group keys (form organizational structure) from configuration.
// These are machine names of detail-groups like 'content_layout', 'spacing', 'default', etc.
// We only want to keep actual configuration keys.
$valid_config_keys = ['theme', 'id', 'custom', 'offset', 'visibility', 'align', 'flex', 'attributes', 'add_region_classes', 'advanced'];
$region = [];
foreach ($region_raw as $key => $value) {
// Keep valid top-level config keys, skip detail-group structure
if (in_array($key, $valid_config_keys) || strpos($key, 'classes_') === 0) {
$region[$key] = $value;
}
}
// Merge form values with existing configuration to preserve presets.
$region = array_merge($existing_config, $region);
if (!empty($region)) {
$region['theme'] = $form_state->getValue([$name, 'theme'], $existing_config['theme']);
$region['id'] = Html::cleanCssIdentifier($form_state->getValue([$name, 'id'], $existing_config['id']));
$region['custom'] = Html::cleanCssIdentifier($form_state->getValue([$name, 'custom'], $existing_config['custom']));
// Ensure these arrays are properly set from form values or existing config.
$region['offset'] = $form_state->getValue([$name, 'offset'], $existing_config['offset']);
$region['visibility'] = $form_state->getValue([$name, 'visibility'], $existing_config['visibility']);
$region['align'] = $form_state->getValue([$name, 'align'], $existing_config['align']);
$region['flex'] = $form_state->getValue([$name, 'flex'], $existing_config['flex']);
// Process dynamic form items from addFormColumnInstances
// Extract utility class values from detail-group structure
$region_values = $form_state->getValue($name, []);
if (!empty($region_values)) {
foreach ($region_values as $type => $type_values) {
// Skip if this is a valid config key (already processed above)
if (in_array($type, $valid_config_keys)) {
continue;
}
// This is a detail-group structure, extract the actual field values
if (!empty($type_values) && is_array($type_values)) {
foreach ($type_values as $item => $item_values) {
if (!empty($item_values) && is_array($item_values)) {
foreach ($item_values as $instance_name => $value) {
// dpm($instance_name);
// Use form value if provided, otherwise fall back to existing config
$region[$instance_name] = !empty($value) ? $value :
($existing_config[$instance_name] ?? []);
}
}
}
}
}
}
$region['add_region_classes'] = $form_state->getValue([$name, 'advanced', 'add_region_classes'], $existing_config['add_region_classes'] ?? 0);
// Apply Xss::filter to attributes.
$region_attributes_value = $form_state->getValue([$name, 'advanced', 'attributes'], $existing_config['attributes'] ?? '');
$region['attributes'] = Xss::filter(is_string($region_attributes_value) ? $region_attributes_value : '');
$regions[$name] = $region;
}
}
$this->configuration['regions'] = $regions;
}
/**
* {@inheritdoc}
*/
public function build(array $regions) {
$build = parent::build($regions);
$configuration = $this->getConfiguration();
// Setup column_classes + column_container + column_theme
// --- Container ---
$build_classes = [];
// Handle container_type as a class (if needed by template)
if (!empty($configuration['container']['container_type'])) {
$build_classes[] = $configuration['container']['container_type'];
}
// Handle container_theme as a class
if (!empty($configuration['container']['container_theme'])) {
$configuration['container']['theme'] = $configuration['container']['container_theme'];
}
// Handle container_theme as a class
if (!empty($configuration['container']['container_visibility'])) {
$build_classes += $configuration['container']['container_visibility'];
}
// Process dynamic utility classes from addFormZoneInstances
foreach ($configuration['container'] as $config_key => $config_value) {
if (strpos($config_key, 'classes_') === 0 && !empty($config_value)) {
// This is a dynamic utility class configuration
// Ensure $config_value is an array before merging
$value_to_merge = is_array($config_value) ? $config_value : [$config_value];
$build_classes = array_merge($build_classes, $value_to_merge);
}
}
// Handle build_classes (string, split by spaces)
if (!empty($configuration['container']['classes'])) {
if (is_array($configuration['container']['classes'])) {
$build_classes = array_merge($build_classes, $configuration['container']['classes']);
} else {
$build_classes = array_merge($build_classes, preg_split('/\s+/', $configuration['container']['classes']));
}
}
//start col_divisions
//end divisions
////////////////
// Remove empty values
$build_classes = array_filter($build_classes, function($item) { return $item !== ''; });
// Add to configuration for use in templates
$configuration['container']['build_classes'] = implode(' ', $build_classes);
// dpm($configuration['container']['build_classes']);
$build_classes_row = [];
if (!empty($configuration['row']['theme'])) {
// theme is a string.
$build_classes_row[]= $configuration['row']['theme'];
$configuration['row']['classes'] = array_merge(
$configuration['row']['classes'] ?? [],
[$configuration['row']['theme']=>$configuration['row']['theme']],
);
}
if (!empty($configuration['row']['offset'])) {
$build_classes_row[]= $configuration['row']['offset'];
$configuration['row']['classes'] = array_merge(
$configuration['row']['classes'] ?? [],
$configuration['row']['offset'],
);
}
if (!empty($configuration['row']['alignment'])) {
$build_classes_row[]= $configuration['row']['theme'];
$configuration['row']['classes'] = array_merge(
$configuration['row']['classes'] ?? [],
$configuration['row']['alignment']
);
}
//start rows_divisions
//end divisions
////////////////
if (!empty($configuration['row']['custom'])) {
// Split the string by spaces into an array.
$customOpts = explode(' ', $configuration['row']['custom']);
// Trim periods from each item and filter out empty items.
$customOpts = array_filter(array_map(function($item) {
return trim($item, '.');
}, $customOpts), function($item) {
return $item !== '';
});
$configuration['row']['classes'] = array_merge(
$configuration['row']['classes'] ?? [],
array_combine($customOpts, $customOpts)
);
}
//dpm($build_classes_row);
//dpm($configuration['row']);
// Setup region/column classes.
if (!empty($configuration['regions'])) {
foreach ($configuration['regions'] as $region_name => &$region_config) {
// If add_base_col is enabled, add the 'col' class at the beginning.
if (!empty($region_config['add_base_col'])) {
$region_config['classes'] = array_merge(
['col' => 'col'],
$region_config['classes'] ?? []
);
}
// If no classes are defined at all (neither preset nor user-selected), ensure a basic column is supplied.
elseif (empty($region_config['classes'])) {
$region_config['classes'] = ['col' => 'col'];
}
// merge in all other sets.
if (!empty($region_config['theme'])) {
$region_config['classes'] = array_merge(
$region_config['classes'] ?? [],
[$region_config['theme'] => $region_config['theme']],
);
}
if (!empty($region_config['align_self'])) {
$region_config['classes'] = array_merge(
$region_config['classes'] ?? [],
$region_config['align_self'],
);
}
if (!empty($region_config['alignment'])) {
$region_config['classes'] = array_merge(
$region_config['classes'] ?? [],
$region_config['alignment']
);
}
// Process dynamic utility classes from addFormColumnInstances
foreach ($region_config as $config_key => $config_value) {
if (strpos($config_key, 'classes_') === 0 && !empty($config_value)) {
// This is a dynamic utility class configuration
// Ensure $config_value is an array before merging
$value_to_merge = is_array($config_value) ? $config_value : [$config_value];
$region_config['classes'] = array_merge(
$region_config['classes'] ?? [],
$value_to_merge
);
}
}
if (!empty($region_config['custom'])) {
// Split the string by spaces into an array.
$customOpts = explode(' ', $region_config['custom']);
$region_config['classes'] = array_merge(
$region_config['classes'] ?? [],
array_combine($customOpts, $customOpts)
);
}
}
}
$this->configuration = $configuration;
//dpm($build);
return $build;
}
private function addFormZoneInstances(&$form, $complete_form_state, $ulility_division, $zone_label, $zone){
$configuration = $this->getConfiguration();
foreach($ulility_division as $type => $fields){
// Convert detail-group to machine name for form structure
$type_machine = ($type == 'default') ? 'default' : $this->makeMachineName($type);
$form[$zone][$type_machine]['#type'] = 'details';
if ($type=='default'){
$form[$zone][$type_machine]['#open'] = TRUE;
$form[$zone][$type_machine]['#title'] = $this->t('Base Utilities');
} else {
$form[$zone][$type_machine]['#open'] = FALSE;
$form[$zone][$type_machine]['#title'] = $this->t('@type Utilities', [
'@type' => $type,
]);
}
if ($type===(string) $this->t('Spacing')){
$form[$zone][$type_machine]['#weight'] = -20;
}
foreach($fields as $field){
$item = $this->makeMachineName($field['item']);
$instance_name = "classes_$item";
$form[$zone][$type_machine][$item][$instance_name] = [
'#title' => $this->t('</q>@zone</q> @title', [
'@title' => $field['field-label'],
'@zone' => $zone_label,
])
] + $field['field'];
// Check for values using both machine name and original type for backward compatibility
$default_value = $complete_form_state->getValue([$zone, $type_machine, $item, $instance_name]);
if ($default_value === NULL) {
$default_value = $complete_form_state->getValue([$zone, $type, $item, $instance_name]);
}
if ($default_value === NULL) {
$default_value = $configuration['row']['wrapper'] ?? [];
}
$form[$zone][$type_machine][$item][$instance_name]['#default_value'] = $default_value;
}
}
return $form;
}
private function addFormColumnInstances(&$form, $default_values, $ulility_division, $region_label, $region){
$configuration = $this->getConfiguration();
foreach($ulility_division as $type => $fields){
// Convert detail-group to machine name for form structure
$type_machine = ($type == 'default') ? 'default' : $this->makeMachineName($type);
$form[$region][$type_machine]['#type'] = 'details';
if ($type=='default'){
$form[$region][$type_machine]['#open'] = TRUE;
$form[$region][$type_machine]['#title'] = $this->t('Base Utilities');
} else {
$form[$region][$type_machine]['#open'] = FALSE;
$form[$region][$type_machine]['#title'] = $this->t('@type Utilities', [
'@type' => $type,
]);
}
if ($type==$this->t('Spacing')){
$form[$region][$type_machine]['#weight'] = -20;
}
foreach($fields as $field){
$item = $this->makeMachineName($field['item']);
$instance_name = "classes_$item";
$form[$region][$type_machine][$item][$instance_name] = [
'#title' => $this->t('<q>@region</q> @title', [
'@title' => $field['field-label'],
'@region' => $region_label,
])
] + $field['field'];
// Check for values using both machine name and original type for backward compatibility
$default_value = $default_values[$instance_name] ?? NULL;
if ($default_value === NULL && isset($configuration['regions'][$region][$type_machine])) {
// Try to get from machine name key
$type_config = $configuration['regions'][$region][$type_machine];
if (isset($type_config[$item][$instance_name])) {
$default_value = $type_config[$item][$instance_name];
}
}
if ($default_value === NULL && isset($configuration['regions'][$region][$type])) {
// Try to get from original type key for backward compatibility
$type_config = $configuration['regions'][$region][$type];
if (isset($type_config[$item][$instance_name])) {
$default_value = $type_config[$item][$instance_name];
}
}
$form[$region][$type_machine][$item][$instance_name]['#default_value'] = $default_value ?? [];
}
}
return $form;
}
private function chooseFileType($default = NULL, $folder = NULL) {
if(isset($folder)){
$folder = $folder ? $folder .'/' : '';
}
$config = $this->configFactory->get('bootstrap_five_layouts.settings');
// get config for this var. *not on admin settngs form at this time.
$max_file_size = $config->get('max_file_size');
$formatted_max_file_size = $this->formatBytes($max_file_size);
$type = [];
$file_type = 'file';
// Determine which file type to use.
if ($this->moduleHandler->moduleExists('media_library_form_element')) {
$file_type = 'media';
}
elseif ($this->moduleHandler->moduleExists('image')) {
$file_type = 'image';
}
$formats = $config->get('allowed_file_types');
switch($file_type) {
case 'media':
$type = [
'#type' => 'media_library',
'#allowed_bundles' => $config->get('allowed_media_types'),
'#title' => t('Background Image'),
'#default_value' => isset($default) ?? NULL,
];
break;
case 'image':
$type = [
'#type' => 'managed_file',
'#title' => t('Background Image'),
'#multiple' => FALSE,
'#upload_location' => 'public://bootstrap-layouts-decor/'. $folder,
'#default_value' => isset($default) ?? NULL,
'#description' => '<ul>'.
'<li>' . t('Allowed extensions: <output>@formats</output>', ['@formats' => $formats]) . '</li>' .
'<li>' . t('Max file size: @formatted_max_file_size', ['@formatted_max_file_size' => $formatted_max_file_size ]) . '</li>' .
'</ul>',
'#upload_validators' => [
'file_validate_extensions' => [$formats],
'file_validate_size' => [$max_file_size],
],
];
break;
case 'file':
default:
//BytesFormatTrait todo show formated allowed size..
$type = [
'#type' => 'file',
'#multiple' => FALSE,
'#title' => t('Background Image'),
'#upload_location' => 'public://bootstrap-layouts-decor/'. $folder,
'#description' => '<ul>'.
'<li>' . t('Allowed extensions: <output>@formats</output>', ['@formats' => $formats]) . '</li>' .
'<li>' . t('Max file size: @formatted_max_file_size', ['@formatted_max_file_size' => $formatted_max_file_size ]) . '</li>' .
'</ul>',
'#upload_validators' => [
'file_validate_extensions' => [$formats],
'file_validate_size' => [$max_file_size],
],
];
break;
}
return $type;
}
/**
* Validates container settings.
*
* @param array $container_values
* The container form values.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*/
private function validateContainerSettings(array $container_values, FormStateInterface $form_state) {
// Validate container classes
if (!empty($container_values['build_classes'])) {
$classes = is_array($container_values['build_classes'])
? $container_values['build_classes']
: preg_split('/\s+/', $container_values['build_classes']);
foreach ($classes as $class) {
$class = trim($class);
if (!empty($class) && !$this->isValidCssClass($class)) {
$form_state->setErrorByName('container][build_classes',
$this->t('Invalid CSS class <q><code>@class</code></q> in container classes.', ['@class' => $class]));
}
}
}
}
/**
* Validates layout settings.
*
* @param array $layout_values
* The layout form values.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*/
private function validateRowSettings(array $layout_values, FormStateInterface $form_state) {
// Validate layout ID
if (!empty($layout_values['id'])) {
if (!$this->isValidHtmlId($layout_values['id'])) {
$form_state->setErrorByName('row][id',
$this->t('Invalid ID <q><code>@id</code></q>.', ['@id' => $layout_values['id']]));
}
}
// Validate custom classes
if (!empty($layout_values['custom'])) {
$classes = preg_split('/\s+/', $layout_va);
$classes = preg_split('/\s+/', $layout_values['custom']);
foreach ($classes as $class) {
$class = trim($class);
if (!empty($class) && !$this->isValidCssClass($class)) {
$form_state->setErrorByName('row][custom',
$this->t('Invalid CSS class <q><code>@class</code></q> in custom classes.', ['@class' => $class]));
}
}
}
// Validate attributes (now in advanced section)
if (!empty($layout_values['advanced']['attributes'])) {
if (!$this->isValidHtmlAttributes($layout_values['advanced']['attributes'])) {
$form_state->setErrorByName('row][advanced][attributes',
$this->t('Invalid attributes format.<br><br>Use format: attribute|value or attribute|<q>value with spaces</q>'));
}
}
}
/**
* Validates region settings.
*
* @param string $region_name
* The region name.
* @param array $region_values
* The region form values.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*/
private function validateRegionSettings($region_name, array $region_values, FormStateInterface $form_state) {
// Validate region ID
if (!empty($region_values['id'])) {
if (!$this->isValidHtmlId($region_values['id'])) {
$form_state->setErrorByName($region_name . '][id',
$this->t('Invalid ID <q><code>@id</code></q> for region "@region".', [
'@id' => $region_values['id'],
'@region' => $region_name
]));
}
}
// Validate custom classes
if (!empty($region_values['custom'])) {
$classes = preg_split('/\s+/', $region_values['custom']);
foreach ($classes as $class) {
$class = trim($class);
if (!empty($class) && !$this->isValidCssClass($class)) {
$form_state->setErrorByName($region_name . '][custom',
$this->t('Invalid CSS class <q><code>@class</code></q> in custom classes for region "@region".', [
'@class' => $class,
'@region' => $region_name
]));
}
}
}
// Validate attributes (now in advanced section)
if (!empty($region_values['advanced']['attributes'])) {
if (!$this->isValidHtmlAttributes($region_values['advanced']['attributes'])) {
$form_state->setErrorByName($region_name . '][advanced][attributes',
$this->t('Invalid attributes format in advanced settings for region "@region".<br><br>Use format: attribute|value or attribute|"value with spaces"', ['@region' => $region_name]));
}
}
}
/**
* Validates that CSS class names are properly formatted.
*
* @param string $class
* The CSS class name to validate.
* @return bool
* TRUE if valid, FALSE otherwise.
*/
protected function isValidCssClass($class) {
// Allow letters, numbers, hyphens, and underscores
// Must start with a letter or underscore
return preg_match('/^[a-zA-Z_-][a-zA-Z0-9_-]*$/', $class) === 1;
}
/**
* Validates that HTML IDs are properly formatted.
*
* @param string $id
* The HTML ID to validate.
* @return bool
* TRUE if valid, FALSE otherwise.
*/
protected function isValidHtmlId($id) {
// Allow letters, numbers, hyphens, underscores, and colons
// Must start with a letter
return preg_match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', $id) === 1;
}
/**
* Validates HTML attributes format.
*
* @param string $attributes
* The attributes string to validate.
* @return bool
* TRUE if valid, FALSE otherwise.
*/
protected function isValidHtmlAttributes($attributes) {
// Check for proper key|value format
// Allow spaces in quoted values
$lines = explode("\n", $attributes);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
// Match patterns like: attribute|value or attribute|"value with spaces"
if (!preg_match('/^[a-zA-Z_-]+\|(?:"[^"]*"|[^\s\|]+)$/', $line)) {
return FALSE;
}
}
return TRUE;
}
/**
* Gets the default container type from configuration.
*
* @return string
* The default container type, defaults to 'container' if config is not available.
*/
protected function getDefaultContainerType() {
// Check if configFactory is available (it might be null during early initialization).
$config = $this->configFactory->get('bootstrap_five_layouts.settings');
$default_container = $config->get('default_container');
if ($default_container) {
return $default_container;
}
// Fallback to 'container' if config is not available or not set.
return 'no-container';
}
protected function makeMachineName($string) {
$machine = mb_strtolower($string);
$machine = preg_replace('/[^a-z0-9_]+/', '_', $machine);
$machine = trim($machine, '_');
return $machine;
}
}
