From 36fa26d5744e46e359f9106cee37cccdc2b92b2e Mon Sep 17 00:00:00 2001 From: Titus TC Date: Sat, 31 Jan 2026 03:44:10 +0200 Subject: [PATCH 1/2] yaml based config --- composer.json | 9 +- composer.lock | 237 ++++++++++++++++++- plugin.php | 4 +- src/Settings/SettingsRenderer.php | 344 ++++++++++++++++++++++++++++ src/Settings/YamlFieldMapper.php | 170 ++++++++++++++ src/Settings/YamlSettingsLoader.php | 230 +++++++++++++++++++ src/Settings/index.php | 25 ++ 7 files changed, 1012 insertions(+), 7 deletions(-) create mode 100644 src/Settings/SettingsRenderer.php create mode 100644 src/Settings/YamlFieldMapper.php create mode 100644 src/Settings/YamlSettingsLoader.php create mode 100644 src/Settings/index.php diff --git a/composer.json b/composer.json index e9376c3..5d39c2a 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,16 @@ "yoast/phpunit-polyfills": "^2.0.5", "tangible/framework": "dev-main" }, - "require": {}, + "require": { + "symfony/yaml": "^7.0" + }, "autoload": { "psr-4": { "Tangible\\": "./src" - } + }, + "files": [ + "src/Settings/index.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/composer.lock b/composer.lock index 48d9e38..b08cf42 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,239 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e5149fd289e805e424aef2e8fe2cc815", - "packages": [], + "content-hash": "b8a24f6f855a9f332d510e6685437bd8", + "packages": [ + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T06:06:12+00:00" + } + ], "packages-dev": [ { "name": "doctrine/instantiator", @@ -1903,5 +2134,5 @@ "prefer-lowest": false, "platform": {}, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/plugin.php b/plugin.php index ede3945..c71c397 100644 --- a/plugin.php +++ b/plugin.php @@ -1,8 +1,8 @@ yamlConfig = $yamlConfig; + } + + /** + * Render the editor/form. + */ + public function render_editor(Layout $layout, array $data = []): string { + $this->ensure_tangible_fields_loaded(); + + $this->layout = $layout; + $this->data = $data; + + $structure = $layout->get_structure(); + + // Plugin name for CSS classes + $name = $this->yamlConfig['name'] ?? str_replace('_', '-', rtrim($this->yamlConfig['prefix'] ?? '', '_')); + $title = $this->yamlConfig['title'] ?? $this->yamlConfig['menu_label'] ?? 'Settings'; + + // Match Framework's page structure + $html = '
'; + + // Header + $html .= '
'; + $html .= '
'; + $html .= '

' . esc_html($title); + $html .= ''; + $html .= '

'; + $html .= '
'; + $html .= '
'; + + // Render layout items (tabs or sections) + foreach ($structure['items'] as $item) { + $html .= $this->render_item($item, $name); + } + + $html .= '
'; // .wrap + + $this->schedule_enqueue(); + + return $html; + } + + /** + * Render a layout item (tabs or section). + */ + protected function render_item(array $item, string $name = ''): string { + return match ($item['type']) { + 'section' => $this->render_section($item), + 'tabs' => $this->render_tabs($item, $name), + default => '', + }; + } + + /** + * Render a section with form-table. + */ + protected function render_section(array $section): string { + $html = '
'; + $html .= '

' . esc_html($section['label']) . '

'; + $html .= ''; + + foreach ($section['fields'] as $field) { + $html .= $this->render_field_row($field); + } + + $html .= ''; + $html .= '
'; + + return $html; + } + + /** + * Render tabs matching Framework's nav-tab structure. + */ + protected function render_tabs(array $tabsStructure, string $name = ''): string { + $currentTab = $_GET['tab'] ?? null; + $tabs = $tabsStructure['tabs']; + + if (empty($tabs)) { + return ''; + } + + // Default to first tab + if ($currentTab === null) { + $currentTab = sanitize_key($tabs[0]['label']); + } + + // Tab navigation - Framework uses h2 + $html = ''; + + // Content wrapper + $html .= '
'; + + // Render active tab content + foreach ($tabs as $tab) { + $tabKey = sanitize_key($tab['label']); + if ($currentTab !== $tabKey) { + continue; + } + + // Form wrapper with Framework classes + $html .= '
'; + + // Title section if defined in YAML + $titleSection = $this->getTabTitleSection($tab['label']); + if ($titleSection) { + $html .= '
'; + $html .= '

' . esc_html($titleSection['title'] ?? $tab['label']) . '

'; + if (!empty($titleSection['description'])) { + $html .= '

' . esc_html($titleSection['description']) . '

'; + } + $html .= '
'; + } + + // Static content (e.g., documentation tab) + $tabContent = $this->getTabContent($tab['label']); + if (!empty($tabContent)) { + $html .= '
'; + $html .= wp_kses_post($tabContent); + $html .= '
'; + } + + // Fields directly in tab + if (!empty($tab['fields'])) { + $html .= '
'; + $html .= '
'; + $html .= ''; + foreach ($tab['fields'] as $field) { + $html .= $this->render_field_row($field); + } + $html .= ''; + $html .= '
'; + $html .= '
'; + } + + // Sections within tab + if (!empty($tab['items'])) { + foreach ($tab['items'] as $item) { + if ($item['type'] === 'section') { + $html .= '
'; + $html .= '
'; + $html .= '

' . esc_html($item['label']) . '

'; + $html .= ''; + foreach ($item['fields'] as $field) { + $html .= $this->render_field_row($field); + } + $html .= ''; + $html .= '
'; + $html .= '
'; + } + } + } + + // Submit button + $html .= '
'; + $html .= get_submit_button('Save Settings', 'primary tpf-button', 'submit', false); + $html .= '
'; + + $html .= '
'; + } + + $html .= '
'; // .tangible-plugin-settings-section-wrapper + + return $html; + } + + /** + * Render a single field row in form-table layout. + */ + protected function render_field_row(array $field): string { + $fields = tangible_fields(); + $slug = $field['slug']; + + $yamlDef = $this->getYamlFieldDef($slug); + $label = $yamlDef['label'] ?? $field['label'] ?? ucfirst(str_replace('_', ' ', $slug)); + $description = $yamlDef['description'] ?? ''; + $type = $yamlDef['type'] ?? 'string'; + $tfType = YamlFieldMapper::getTfType($type); + + $value = $this->data[$slug] ?? $yamlDef['default'] ?? ''; + + $showDescInLabel = ($tfType !== 'switch' && !empty($description)); + + $html = ''; + $html .= ''; + $html .= ''; + if ($showDescInLabel) { + $html .= '

' . esc_html($description) . '

'; + } + $html .= ''; + $html .= ''; + + $settingsKey = $this->yamlConfig['slug'] ?? ''; + $fieldConfig = YamlFieldMapper::toTangibleFieldsConfig($slug, $yamlDef, $settingsKey); + $fieldConfig['value'] = $this->formatValue($value, $type); + + $fields->register_field($slug, $fieldConfig); + $html .= $fields->render_field($slug); + + $html .= ''; + $html .= ''; + + return $html; + } + + /** + * Get tab content from YAML config by label. + */ + protected function getTabContent(string $label): string { + foreach ($this->yamlConfig['tabs'] ?? [] as $tabConfig) { + if (($tabConfig['label'] ?? '') === $label && isset($tabConfig['content'])) { + return $tabConfig['content']; + } + } + return ''; + } + + /** + * Get tab title_section from YAML config by label. + */ + protected function getTabTitleSection(string $label): ?array { + foreach ($this->yamlConfig['tabs'] ?? [] as $tabConfig) { + if (($tabConfig['label'] ?? '') === $label && isset($tabConfig['title_section'])) { + return $tabConfig['title_section']; + } + } + return null; + } + + /** + * Get YAML field definition by slug. + */ + protected function getYamlFieldDef(string $slug): array { + $prefix = $this->yamlConfig['prefix'] ?? ''; + $shortName = str_starts_with($slug, $prefix) ? substr($slug, strlen($prefix)) : $slug; + + foreach ($this->yamlConfig['tabs'] ?? [] as $tab) { + if (isset($tab['fields'][$shortName])) { + return $tab['fields'][$shortName]; + } + foreach ($tab['sections'] ?? [] as $section) { + if (isset($section['fields'][$shortName])) { + return $section['fields'][$shortName]; + } + } + } + + return []; + } + + /** + * Format value for Tangible Fields. + */ + protected function formatValue(mixed $value, string $type): mixed { + if ($value === null) { + return ''; + } + + return match ($type) { + 'boolean' => (bool) $value, + 'integer', 'number' => (int) $value, + default => $value, + }; + } + + /** + * Render list view (not used for settings). + */ + public function render_list(DataSet $dataset, array $entities): string { + return '

List view not available for settings.

'; + } + + /** + * Enqueue assets. + */ + public function enqueue_assets(): void { + if ($this->enqueued) { + return; + } + + $this->ensure_tangible_fields_loaded(); + tangible_fields()->enqueue(); + + // Enqueue Framework settings CSS if available + $framework = function_exists('tangible') ? tangible() : null; + if ($framework && !empty($framework->url)) { + wp_enqueue_style( + 'tangible-plugin-settings-page', + $framework->url . '/assets/settings.css', + [], + $framework->version ?? '1.0.0' + ); + } + + $this->enqueued = true; + } + + /** + * Schedule asset enqueue on footer. + */ + protected function schedule_enqueue(): void { + if ($this->enqueued) { + return; + } + add_action('admin_footer', [$this, 'enqueue_assets'], 5); + } + + /** + * Ensure Tangible Fields is available. + */ + protected function ensure_tangible_fields_loaded(): void { + if (!function_exists('tangible_fields')) { + throw new \RuntimeException('SettingsRenderer requires Tangible Fields.'); + } + } +} diff --git a/src/Settings/YamlFieldMapper.php b/src/Settings/YamlFieldMapper.php new file mode 100644 index 0000000..c0adac3 --- /dev/null +++ b/src/Settings/YamlFieldMapper.php @@ -0,0 +1,170 @@ + 'string', + 'text' => 'string', + 'integer' => 'integer', + 'number' => 'integer', + 'boolean' => 'boolean', + 'email' => 'string', + 'url' => 'string', + 'select' => 'string', + 'radio' => 'string', + 'multiselect' => 'string', + 'dimension' => 'string', + 'date' => 'string', + ]; + + /** + * Map YAML type to Tangible Fields type. + */ + protected static array $tfTypeMap = [ + 'string' => 'text', + 'text' => 'textarea', + 'integer' => 'number', + 'number' => 'number', + 'boolean' => 'switch', + 'email' => 'text', + 'url' => 'text', + 'select' => 'select', + 'radio' => 'radio', + 'multiselect' => 'checkboxMultiselect', + 'dimension' => 'simple_dimension', + 'date' => 'date_picker', + ]; + + /** + * Convert YAML field definition to DataView field config. + */ + public static function toDataViewField(array $fieldDef): array { + $type = $fieldDef['type'] ?? 'string'; + + return [ + 'type' => self::$typeMap[$type] ?? 'string', + 'label' => $fieldDef['label'] ?? '', + 'description' => $fieldDef['description'] ?? '', + 'placeholder' => $fieldDef['placeholder'] ?? '', + 'default' => $fieldDef['default'] ?? null, + 'required' => $fieldDef['required'] ?? false, + // Pass through all original config for renderer + '_yaml' => $fieldDef, + ]; + } + + /** + * Convert YAML field definition to Tangible Fields render config. + */ + public static function toTangibleFieldsConfig(string $name, array $fieldDef, string $settingsKey = ''): array { + $type = $fieldDef['type'] ?? 'string'; + $tfType = self::$tfTypeMap[$type] ?? 'text'; + + $config = [ + 'type' => $tfType, + 'name' => $settingsKey ? "{$settingsKey}[{$name}]" : $name, + 'label' => '', // Label rendered separately in table layout + 'placeholder' => $fieldDef['placeholder'] ?? '', + ]; + + // Switch-specific: description shows inline + if ($tfType === 'switch') { + $config['description'] = $fieldDef['description'] ?? ''; + $config['value_on'] = true; + $config['value_off'] = false; + } + + // Number-specific + if ($tfType === 'number') { + if (isset($fieldDef['min'])) $config['min'] = $fieldDef['min']; + if (isset($fieldDef['max'])) $config['max'] = $fieldDef['max']; + } + + // Choices for select/radio/multiselect + if (!empty($fieldDef['choices'])) { + $config['choices'] = $fieldDef['choices']; + } + + // Dimension units + if ($tfType === 'simple_dimension' && !empty($fieldDef['units'])) { + $config['units'] = $fieldDef['units']; + } + + // Dynamic tags + if (!empty($fieldDef['dynamic'])) { + $config['dynamic'] = [ + 'mode' => 'insert', + 'categories' => $fieldDef['dynamic_categories'] ?? [], + ]; + } + + // Conditions + if (!empty($fieldDef['condition'])) { + $config['condition'] = self::mapCondition($fieldDef['condition'], $settingsKey); + } + + return $config; + } + + /** + * Map simplified YAML condition to TFields format. + * + * YAML format: + * ```yaml + * condition: + * field: other_field + * equals: value + * ``` + * + * TFields format: + * ```php + * 'condition' => [ + * 'action' => 'show', + * 'condition' => ['settings_key[other_field]' => ['_eq' => 'value']] + * ] + * ``` + */ + public static function mapCondition(array $condition, string $settingsKey = ''): array { + $field = $condition['field'] ?? ''; + $fieldName = $settingsKey ? "{$settingsKey}[{$field}]" : $field; + + $tfCondition = []; + + if (isset($condition['equals'])) { + $tfCondition[$fieldName] = ['_eq' => $condition['equals']]; + } elseif (isset($condition['not_equals'])) { + $tfCondition[$fieldName] = ['_neq' => $condition['not_equals']]; + } elseif (isset($condition['in'])) { + $tfCondition[$fieldName] = ['_in' => $condition['in']]; + } elseif (isset($condition['not_in'])) { + $tfCondition[$fieldName] = ['_nin' => $condition['not_in']]; + } + + return [ + 'action' => 'show', + 'condition' => $tfCondition, + ]; + } + + /** + * Get the Tangible Fields type for a YAML type. + */ + public static function getTfType(string $yamlType): string { + return self::$tfTypeMap[$yamlType] ?? 'text'; + } + + /** + * Get the DataView type for a YAML type. + */ + public static function getDataViewType(string $yamlType): string { + return self::$typeMap[$yamlType] ?? 'string'; + } +} diff --git a/src/Settings/YamlSettingsLoader.php b/src/Settings/YamlSettingsLoader.php new file mode 100644 index 0000000..be6c0c3 --- /dev/null +++ b/src/Settings/YamlSettingsLoader.php @@ -0,0 +1,230 @@ +plugin = $plugin; + $this->config = $this->applyDefaults($config); + } + + /** + * Register settings from a YAML file. + */ + public static function register(object $plugin, string $yaml_path): ?DataView { + if (!file_exists($yaml_path)) { + trigger_error("Settings YAML not found: {$yaml_path}", E_USER_WARNING); + return null; + } + + $config = Yaml::parseFile($yaml_path); + $loader = new self($plugin, $config); + + return $loader->createDataView(); + } + + /** + * Apply default values inferred from $plugin. + */ + protected function applyDefaults(array $config): array { + $pluginPrefix = $this->plugin->setting_prefix ?? str_replace('-', '_', $this->plugin->name); + + $defaults = [ + 'menu_label' => 'Settings', + 'parent' => $this->plugin->name, + 'prefix' => $pluginPrefix . '_', + 'capability' => 'manage_options', + ]; + + $merged = array_merge($defaults, $config); + + // Slug depends on prefix, so calculate after merge + if (!isset($merged['slug'])) { + $prefix = rtrim($merged['prefix'], '_'); + $merged['slug'] = $prefix . '_settings'; + } + + return $merged; + } + + /** + * Create and register the DataView. + */ + public function createDataView(): DataView { + $fields = $this->extractAllFields(); + + $this->view = new DataView([ + 'slug' => $this->config['slug'], + 'label' => 'Settings', + 'mode' => 'singular', + 'storage' => 'option', + 'capability' => $this->config['capability'], + 'fields' => $fields, + 'ui' => [ + 'menu_label' => $this->config['menu_label'], + 'parent' => $this->config['parent'], + ], + ]); + + $this->view->set_layout(fn(Layout $layout) => $this->buildLayout($layout)); + $this->view->set_renderer(new SettingsRenderer($this->config)); + + // Register dynamic values if defined + if (!empty($this->config['dynamic_values'])) { + $this->registerDynamicValues(); + } + + add_action('admin_menu', fn() => $this->view->register(), 60); + + return $this->view; + } + + /** + * Extract all fields from tabs/sections into flat array for DataView. + */ + protected function extractAllFields(): array { + $fields = []; + $prefix = $this->config['prefix']; + + foreach ($this->config['tabs'] ?? [] as $tabKey => $tab) { + // Fields directly in tab + foreach ($tab['fields'] ?? [] as $fieldKey => $fieldDef) { + $fullKey = $prefix . $fieldKey; + $fields[$fullKey] = YamlFieldMapper::toDataViewField($fieldDef); + } + + // Fields in sections + foreach ($tab['sections'] ?? [] as $sectionKey => $section) { + foreach ($section['fields'] ?? [] as $fieldKey => $fieldDef) { + $fullKey = $prefix . $fieldKey; + $fields[$fullKey] = YamlFieldMapper::toDataViewField($fieldDef); + } + } + } + + return $fields; + } + + /** + * Build the layout from YAML config. + */ + protected function buildLayout(Layout $layout): void { + $tabs = $this->config['tabs'] ?? []; + + if (empty($tabs)) { + return; + } + + $layout->tabs(function(Tabs $tabsBuilder) use ($tabs) { + foreach ($tabs as $tabKey => $tabConfig) { + $tabsBuilder->tab($tabConfig['label'] ?? ucfirst($tabKey), function(Tab $tab) use ($tabConfig) { + $this->buildTabContent($tab, $tabConfig); + }); + } + }); + } + + /** + * Build content for a single tab. + */ + protected function buildTabContent(Tab $tab, array $tabConfig): void { + $prefix = $this->config['prefix']; + + // Add sections if present + if (!empty($tabConfig['sections'])) { + foreach ($tabConfig['sections'] as $sectionKey => $sectionConfig) { + $tab->section($sectionConfig['label'] ?? ucfirst($sectionKey), function(Section $section) use ($sectionConfig, $prefix) { + foreach ($sectionConfig['fields'] ?? [] as $fieldKey => $fieldDef) { + $section->field($prefix . $fieldKey); + } + }); + } + } + + // Add fields directly in tab (no section) + foreach ($tabConfig['fields'] ?? [] as $fieldKey => $fieldDef) { + $tab->field($prefix . $fieldKey); + } + } + + /** + * Register dynamic values for template tags. + */ + protected function registerDynamicValues(): void { + if (!function_exists('tangible_fields')) { + return; + } + + $fields = tangible_fields(); + $dvConfig = $this->config['dynamic_values']; + + // Register category + if (!empty($dvConfig['category'])) { + $fields->register_dynamic_value_category($dvConfig['category'], [ + 'label' => $dvConfig['label'] ?? ucfirst($dvConfig['category']), + ]); + } + + // Register values + foreach ($dvConfig['values'] ?? [] as $name => $valueDef) { + $callback = $valueDef['callback'] ?? null; + + if (is_string($callback) && is_callable($callback)) { + $callbackFn = fn($settings, $config) => call_user_func($callback); + } elseif (is_string($callback) && strpos($callback, '::') !== false) { + $callbackFn = fn($settings, $config) => call_user_func($callback); + } else { + $callbackFn = fn() => ''; + } + + $fields->register_dynamic_value([ + 'category' => $dvConfig['category'] ?? 'general', + 'name' => $name, + 'label' => $valueDef['label'] ?? ucfirst($name), + 'type' => 'text', + 'callback' => $callbackFn, + 'permission_callback' => '__return_true', + ]); + } + } + + /** + * Get the created DataView. + */ + public function getView(): ?DataView { + return $this->view; + } + + /** + * Get the parsed config. + */ + public function getConfig(): array { + return $this->config; + } +} diff --git a/src/Settings/index.php b/src/Settings/index.php new file mode 100644 index 0000000..9c7e38f --- /dev/null +++ b/src/Settings/index.php @@ -0,0 +1,25 @@ + Date: Mon, 16 Feb 2026 19:56:42 +0200 Subject: [PATCH 2/2] Fix settings save: extract nested POST data from settings key The SettingsRenderer nests field names under the settings slug (e.g., settings_slug[field_name]), but extract_post_data() was looking for flat $_POST[field_name]. Values were silently skipped on every save, resulting in empty settings. Check $_POST[slug][field_name] first, fall back to flat for compat. Co-Authored-By: Claude Opus 4.6 --- src/DataView/RequestRouter.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/DataView/RequestRouter.php b/src/DataView/RequestRouter.php index 123767b..089079c 100644 --- a/src/DataView/RequestRouter.php +++ b/src/DataView/RequestRouter.php @@ -423,16 +423,27 @@ protected function handle_settings_submit(): void { /** * Extract and sanitize POST data based on field types. * + * In singular mode, the SettingsRenderer nests field names under the + * settings key (e.g., `settings_slug[field_name]`), so POST data arrives + * as `$_POST['settings_slug']['field_name']`. We check the nested array + * first, then fall back to flat `$_POST['field_name']` for compatibility. + * * @return array Sanitized data. */ protected function extract_post_data(): array { $data = []; + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $nested = $_POST[ $this->config->slug ] ?? []; + foreach ( $this->config->field_configs as $name => $config ) { $type = $config['type']; + // Check nested array first (singular/settings mode), then flat POST // phpcs:ignore WordPress.Security.NonceVerification.Missing - if ( ! isset( $_POST[ $name ] ) ) { + $has_value = isset( $nested[ $name ] ) || isset( $_POST[ $name ] ); + + if ( ! $has_value ) { // Handle missing boolean fields (unchecked checkboxes). if ( $this->registry->get_dataset_type( $type ) === DataSet::TYPE_BOOLEAN ) { $data[ $name ] = false; @@ -446,7 +457,8 @@ protected function extract_post_data(): array { $sanitizer = $this->registry->get_sanitizer( $type ); // phpcs:ignore WordPress.Security.NonceVerification.Missing - $data[ $name ] = $sanitizer( $_POST[ $name ] ); + $raw_value = $nested[ $name ] ?? $_POST[ $name ]; + $data[ $name ] = $sanitizer( $raw_value ); } return $data;