feat: added Settings, Sanitizer & PageRenderer

This commit is contained in:
owaisahmed5300 2025-08-12 00:17:32 +05:00
parent cb9e9acd43
commit da91e781ae
4 changed files with 580 additions and 2 deletions

View file

@ -11,8 +11,8 @@ parameters:
strictRules: strictRules:
noVariableVariables: false noVariableVariables: false


# ignoreErrors: ignoreErrors:
# - identifier: empty.notAllowed - identifier: empty.notAllowed


treatPhpDocTypesAsCertain: false treatPhpDocTypesAsCertain: false



217
src/PageRenderer.php Normal file
View file

@ -0,0 +1,217 @@
<?php

declare(strict_types=1);

namespace WPTechnix\WPSettings;

/**
* Handles all HTML output for the settings page.
*
* This class isolates the presentation logic from the business logic. It is
* responsible for rendering the main page wrapper, navigation tabs, and the
* settings form itself.
*
* @noinspection HtmlUnknownAttribute
*/
final class PageRenderer
{
/**
* The main settings configuration array.
*
* @var array<string, mixed>
*/
private array $config;

/**
* The FieldFactory instance for creating field objects.
*
* @var FieldFactory
*/
private FieldFactory $fieldFactory;

/**
* PageRenderer constructor.
*
* @param array<string, mixed> $config The main settings configuration.
* @param FieldFactory $fieldFactory The factory for creating field objects.
*/
public function __construct(array $config, FieldFactory $fieldFactory)
{
$this->config = $config;
$this->fieldFactory = $fieldFactory;
}

/**
* Renders the entire settings page.
*
* This is the main callback function for `add_submenu_page`. It includes
* a capability check and renders the page structure.
*/
public function renderPage(): void
{
if (!empty($this->config['capability']) && !current_user_can($this->config['capability'])) {
wp_die(esc_html($this->config['labels']['noPermission'] ?? 'Permission denied.'));
}

$activeTab = $this->getActiveTab();
?>
<div class="wrap">
<h1><?php echo esc_html($this->config['pageTitle']); ?></h1>

<?php if (!empty($this->config['useTabs']) && !empty($this->config['tabs'])) : ?>
<?php $this->renderTabs($activeTab); ?>
<?php endif; ?>

<!--suppress HtmlUnknownTarget -->
<form method="post" action="options.php">
<?php
settings_fields($this->config['optionGroup']);

if (!empty($this->config['useTabs']) && !empty($activeTab)) {
$this->renderSectionsForTab($activeTab);
} else {
do_settings_sections($this->config['pageSlug']);
}

submit_button();
?>
</form>
</div>
<?php
}

/**
* The WordPress callback for rendering a settings field.
*
* This method passes the `htmlPrefix` to the field configuration, ensuring
* that the rendered field's HTML has the correct CSS classes.
*
* @param array<string, mixed> $args Arguments passed from `add_settings_field`.
* @return void
*/
public function renderField(array $args): void
{
$fieldId = $args['id'] ?? '';
$fieldConfig = $this->config['fields'][$fieldId] ?? null;

if (empty($fieldConfig)) {
return; // Safety check.
}

$htmlPrefix = $this->config['htmlPrefix'] ?? 'wptechnix-settings';
$fieldConfig['htmlPrefix'] = $htmlPrefix; // Pass the prefix so that later we can use in fields.

$options = get_option($this->config['optionName'], []);
$value = $options[$fieldId] ?? $fieldConfig['default'] ?? null;

try {
$field = $this->fieldFactory->create($fieldConfig['type'], $fieldConfig);

$conditionalAttr = '';
if (!empty($fieldConfig['conditional'])) {
$cond = $fieldConfig['conditional'];
$conditionalAttr = sprintf(
'data-conditional="%s" data-conditional-value="%s" data-conditional-operator="%s"',
esc_attr($cond['field'] ?? ''),
esc_attr((string) ($cond['value'] ?? '')),
esc_attr($cond['operator'] ?? '==')
);
}

printf('<div class="%s-field-container" %s>', esc_attr($htmlPrefix), $conditionalAttr);
$field->render($value, $fieldConfig['attributes'] ?? []);

if (!empty($fieldConfig['description']) && 'description' !== $fieldConfig['type']) {
echo '<p class="description">' . wp_kses_post($fieldConfig['description']) . '</p>';
}
echo '</div>';
} catch (\InvalidArgumentException $e) {
echo '<p><strong>Error:</strong> ' . esc_html($e->getMessage()) . '</p>';
}
}

/**
* Renders the navigation tabs for the settings page.
*
* @param string $activeTab The slug of the currently active tab.
*/
private function renderTabs(string $activeTab): void
{
echo '<nav class="nav-tab-wrapper" style="margin-bottom: 20px;">';
foreach ($this->config['tabs'] as $tabId => $tab) {
$url = add_query_arg(['page' => $this->config['pageSlug'], 'tab' => $tabId]);
$class = 'nav-tab' . ($activeTab === $tabId ? ' nav-tab-active' : '');
printf(
'<a href="%s" class="%s">%s %s</a>',
esc_url($url),
esc_attr($class),
!empty($tab['icon']) ?
'<span class="dashicons ' . esc_attr($tab['icon']) . '" style="margin-right: 5px;"></span>'
: '',
esc_html($tab['title'])
);
}
echo '</nav>';
}

/**
* Renders all settings sections associated with a specific tab.
*
* @param string $activeTab The slug of the currently active tab.
*/
private function renderSectionsForTab(string $activeTab): void
{
global $wp_settings_sections, $wp_settings_fields;

$page = $this->config['pageSlug'];

if (empty($wp_settings_sections[$page])) {
return;
}

foreach ((array) $wp_settings_sections[$page] as $section) {
$sectionTab = $this->config['sections'][$section['id']]['tab'] ?? '';
if ($sectionTab !== $activeTab) {
continue;
}

if ($section['title']) {
echo "<h2>" . esc_html($section['title']) . "</h2>\n";
}

if ($section['callback']) {
call_user_func($section['callback'], $section);
}

if (
!isset($wp_settings_fields) ||
!isset($wp_settings_fields[$page]) ||
!isset($wp_settings_fields[$page][$section['id']])
) {
continue;
}
echo '<table class="form-table" role="presentation">';
do_settings_fields($page, $section['id']);
echo '</table>';
}
}


/**
* Determines the currently active tab from the URL query string.
*
* @return string The slug of the active tab, or an empty string if none.
*/
private function getActiveTab(): string
{
if (empty($this->config['useTabs']) || empty($this->config['tabs'])) {
return '';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$activeTab = sanitize_text_field($_GET['tab'] ?? '');
if (empty($activeTab) || !isset($this->config['tabs'][$activeTab])) {
return array_key_first($this->config['tabs']) ?? '';
}
return $activeTab;
}
}

107
src/Sanitizer.php Normal file
View file

@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace WPTechnix\WPSettings;

use InvalidArgumentException;

/**
* Sanitizes the settings array before it is saved to the database.
*
* This class ensures that all data conforms to the expected format and is
* safe for storage. It iterates through all registered fields and applies
* the appropriate sanitization logic.
*/
final class Sanitizer
{
/**
* The full settings configuration array.
*
* @var array<string, mixed>
*/
private array $config;

/**
* The FieldFactory instance for creating field objects.
*
* @var FieldFactory
*/
private FieldFactory $fieldFactory;

/**
* Sanitizer constructor.
*
* @param array<string, mixed> $config The settings configuration.
* @param FieldFactory $fieldFactory The factory for creating field objects.
*/
public function __construct(array $config, FieldFactory $fieldFactory)
{
$this->config = $config;
$this->fieldFactory = $fieldFactory;
}

/**
* Sanitizes the entire settings array.
*
* This is the main callback for the 'sanitize_callback' argument in
* `register_setting`. It processes the raw input from the $_POST array.
*
* @param mixed $input The raw input from the form submission.
* @return array<string, mixed> The sanitized settings array ready for saving.
*/
public function sanitize(mixed $input): array
{
if (!is_array($input)) {
return [];
}

$sanitized = [];
$fields = $this->config['fields'] ?? [];

foreach ($fields as $fieldId => $fieldConfig) {
// Description fields have no value and should be skipped.
if ('description' === $fieldConfig['type']) {
continue;
}

$rawValue = $input[$fieldId] ?? null;

try {
$field = $this->fieldFactory->create($fieldConfig['type'], $fieldConfig);
$defaultValue = $field->getDefaultValue();

// If value is not submitted, use default.
if (null === $rawValue) {
$sanitized[$fieldId] = $defaultValue;
continue;
}

// Apply the field's specific sanitization method.
$sanitizedValue = $field->sanitize($rawValue);

// Apply custom validation callback if it exists.
if (isset($fieldConfig['validate_callback']) && is_callable($fieldConfig['validate_callback'])) {
if (!call_user_func($fieldConfig['validate_callback'], $sanitizedValue)) {
// If validation fails, revert to the default value.
$sanitizedValue = $defaultValue;
add_settings_error(
$this->config['optionGroup'],
'validation_error_' . $fieldId,
'Invalid value provided for ' . $fieldConfig['label'] . '. Reverted to default.',
'error'
);
}
}

$sanitized[$fieldId] = $sanitizedValue;
} catch (InvalidArgumentException) {
// This should not happen if types are validated on creation.
// For safety, we use the default value.
$sanitized[$fieldId] = $fieldConfig['default'] ?? '';
}
}

return $sanitized;
}
}

254
src/Settings.php Normal file
View file

@ -0,0 +1,254 @@
<?php

declare(strict_types=1);

namespace WPTechnix\WPSettings;

use InvalidArgumentException;
use WPTechnix\WPSettings\Interfaces\SettingsInterface;

/**
* A fluent builder for creating and managing WordPress admin settings pages.
*/
class Settings implements SettingsInterface
{
/**
* The settings configuration array.
*
* @var array<string, mixed>
*/
private array $config = [
'tabs' => [],
'sections' => [],
'fields' => [],
];

/**
* The FieldFactory instance.
*
* @var FieldFactory
*/
private FieldFactory $fieldFactory;

/**
* A cached copy of the settings options array from the database.
*
* @var array<string, mixed>|null
*/
private ?array $options = null;

/**
* Settings constructor.
*
* @param string $pageSlug The unique settings page slug.
* @param string|null $pageTitle Optional. The title for the settings page. Defaults to "Settings".
* @param string|null $menuTitle Optional. The title for the admin menu. Defaults to the page title.
* @param array<string, mixed> $options Optional configuration overrides.
*/
public function __construct(
string $pageSlug,
?string $pageTitle = null,
?string $menuTitle = null,
array $options = []
) {
if (empty($pageSlug)) {
throw new InvalidArgumentException('Page slug cannot be empty.');
}

$this->fieldFactory = new FieldFactory();

$finalPageTitle = $pageTitle ?? __('Settings', 'default');
$finalMenuTitle = $menuTitle ?? $finalPageTitle;

$this->config = array_replace_recursive(
[
'pageSlug' => $pageSlug,
'pageTitle' => $finalPageTitle,
'menuTitle' => $finalMenuTitle,
'capability' => 'manage_options',
'parentSlug' => 'options-general.php',
'useTabs' => false,
'optionName' => $pageSlug . '_settings',
'optionGroup' => $pageSlug . '_settings_group',
'htmlPrefix' => 'wptechnix-settings', // no underscore or dash in end.
'labels' => [
'noPermission' => __('You do not have permission to access this page.', 'default'),
'selectMedia' => __('Select Media', 'default'),
'remove' => __('Remove', 'default'),
],
],
$options
);
}

/**
* {@inheritDoc}
*/
public function setPageTitle(string $pageTitle): static
{
$this->config['pageTitle'] = $pageTitle;
return $this;
}

/**
* {@inheritDoc}
*/
public function setMenuTitle(string $menuTitle): static
{
$this->config['menuTitle'] = $menuTitle;
return $this;
}

/**
* {@inheritDoc}
*/
public function addTab(string $id, string $title, string $icon = ''): static
{
$this->config['tabs'][$id] = ['title' => $title, 'icon' => $icon];
$this->config['useTabs'] = true;
return $this;
}

/**
* {@inheritDoc}
*/
public function addSection(string $id, string $title, string $description = '', string $tabId = ''): static
{
$this->config['sections'][$id] = ['title' => $title, 'description' => $description, 'tab' => $tabId];
return $this;
}

/**
* {@inheritDoc}
*/
public function addField(
string $id,
string $sectionId,
string $type,
string $label,
array $args = []
): static {
if (!isset($this->config['sections'][$sectionId])) {
throw new InvalidArgumentException("Section '{$sectionId}' must be added before adding fields to it.");
}
if (!in_array($type, $this->fieldFactory->getSupportedTypes(), true)) {
throw new InvalidArgumentException("Field type '{$type}' is not supported.");
}

$this->config['fields'][$id] = array_merge(
[
'id' => $id,
'name' => $this->config['optionName'] . '[' . $id . ']',
'section' => $sectionId,
'type' => $type,
'label' => $label,
'description' => '',
],
$args
);
return $this;
}

/**
* {@inheritDoc}
*/
public function init(): void
{
add_action('admin_menu', [$this, 'registerPage']);
add_action('admin_init', [$this, 'registerSettings']);
}

/**
* Registers the settings page with the WordPress admin menu.
*
* @internal
*/
public function registerPage(): void
{
$renderer = new PageRenderer($this->config, $this->fieldFactory);

add_submenu_page(
$this->config['parentSlug'],
$this->config['pageTitle'],
$this->config['menuTitle'],
$this->config['capability'],
$this->config['pageSlug'],
[$renderer, 'renderPage']
);
}

/**
* Registers the settings, sections, and fields with the WordPress Settings API.
*
* @internal
*/
public function registerSettings(): void
{
$sanitizer = new Sanitizer($this->config, $this->fieldFactory);
$renderer = new PageRenderer($this->config, $this->fieldFactory);

register_setting(
$this->config['optionGroup'],
$this->config['optionName'],
['sanitize_callback' => [$sanitizer, 'sanitize']]
);

foreach ($this->config['sections'] as $id => $section) {
add_settings_section(
$id,
$section['title'],
!empty($section['description'])
? fn() => print('<p class="section-description">' . wp_kses_post($section['description']) . '</p>')
: '__return_null',
$this->config['pageSlug']
);
}

foreach ($this->config['fields'] as $id => $field) {
add_settings_field(
$id,
$field['label'],
[$renderer, 'renderField'],
$this->config['pageSlug'],
$field['section'],
['id' => $id, 'label_for' => $id]
);
}
}

/**
* {@inheritDoc}
*/
public function getOptionName(): string
{
return $this->config['optionName'];
}


/**
* {@inheritDoc}
*/
public function get(string $key, mixed $default = null): mixed
{
// First, check if the options have already been fetched for this request.
if (!isset($this->options)) {
// If not, fetch from the database once and cache the result.
$this->options = get_option($this->getOptionName(), []);
if (!is_array($this->options)) {
$this->options = [];
}
}

// Return the value from the cached array, or the provided default.
if (isset($this->options[$key])) {
return $this->options[$key];
}

// If the key is not in the options, check for a configured default for that field.
if (isset($this->config['fields'][$key]['default'])) {
return $this->config['fields'][$key]['default'];
}

return $default;
}
}