mirror of
https://github.com/WPTechnix/wp-settings-framework.git
synced 2025-10-04 02:26:00 +08:00
feat: added Settings, Sanitizer & PageRenderer
This commit is contained in:
parent
cb9e9acd43
commit
da91e781ae
4 changed files with 580 additions and 2 deletions
|
@ -11,8 +11,8 @@ parameters:
|
|||
strictRules:
|
||||
noVariableVariables: false
|
||||
|
||||
# ignoreErrors:
|
||||
# - identifier: empty.notAllowed
|
||||
ignoreErrors:
|
||||
- identifier: empty.notAllowed
|
||||
|
||||
treatPhpDocTypesAsCertain: false
|
||||
|
||||
|
|
217
src/PageRenderer.php
Normal file
217
src/PageRenderer.php
Normal 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
107
src/Sanitizer.php
Normal 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
254
src/Settings.php
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue