From da91e781ae7db48e5bb3a54c2430c97e5d11cb5a Mon Sep 17 00:00:00 2001 From: owaisahmed5300 Date: Tue, 12 Aug 2025 00:17:32 +0500 Subject: [PATCH] feat: added Settings, Sanitizer & PageRenderer --- phpstan.neon.dist | 4 +- src/PageRenderer.php | 217 ++++++++++++++++++++++++++++++++++++ src/Sanitizer.php | 107 ++++++++++++++++++ src/Settings.php | 254 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 src/PageRenderer.php create mode 100644 src/Sanitizer.php create mode 100644 src/Settings.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 603ec14..0945518 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,8 +11,8 @@ parameters: strictRules: noVariableVariables: false -# ignoreErrors: -# - identifier: empty.notAllowed + ignoreErrors: + - identifier: empty.notAllowed treatPhpDocTypesAsCertain: false diff --git a/src/PageRenderer.php b/src/PageRenderer.php new file mode 100644 index 0000000..e6d4479 --- /dev/null +++ b/src/PageRenderer.php @@ -0,0 +1,217 @@ + + */ + private array $config; + + /** + * The FieldFactory instance for creating field objects. + * + * @var FieldFactory + */ + private FieldFactory $fieldFactory; + + /** + * PageRenderer constructor. + * + * @param array $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(); + ?> +
+

config['pageTitle']); ?>

+ + config['useTabs']) && !empty($this->config['tabs'])) : ?> + renderTabs($activeTab); ?> + + + +
+ config['optionGroup']); + + if (!empty($this->config['useTabs']) && !empty($activeTab)) { + $this->renderSectionsForTab($activeTab); + } else { + do_settings_sections($this->config['pageSlug']); + } + + submit_button(); + ?> +
+
+ $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('
', esc_attr($htmlPrefix), $conditionalAttr); + $field->render($value, $fieldConfig['attributes'] ?? []); + + if (!empty($fieldConfig['description']) && 'description' !== $fieldConfig['type']) { + echo '

' . wp_kses_post($fieldConfig['description']) . '

'; + } + echo '
'; + } catch (\InvalidArgumentException $e) { + echo '

Error: ' . esc_html($e->getMessage()) . '

'; + } + } + + /** + * 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 ''; + } + + /** + * 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 "

" . esc_html($section['title']) . "

\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 ''; + do_settings_fields($page, $section['id']); + echo ''; + } + } + + + /** + * 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; + } +} diff --git a/src/Sanitizer.php b/src/Sanitizer.php new file mode 100644 index 0000000..2291bf0 --- /dev/null +++ b/src/Sanitizer.php @@ -0,0 +1,107 @@ + + */ + private array $config; + + /** + * The FieldFactory instance for creating field objects. + * + * @var FieldFactory + */ + private FieldFactory $fieldFactory; + + /** + * Sanitizer constructor. + * + * @param array $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 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; + } +} diff --git a/src/Settings.php b/src/Settings.php new file mode 100644 index 0000000..437f8ec --- /dev/null +++ b/src/Settings.php @@ -0,0 +1,254 @@ + + */ + 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|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 $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('

' . wp_kses_post($section['description']) . '

') + : '__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; + } +}