feat: DynamicContentMap::loopFields() – loop_*-Optionen nach Art (X2); echte Loop-Optionen in DiviFixtures
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
617ad2888d
commit
3d12043b4f
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
|||||||
use Closure;
|
use Closure;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||||||
use LIT\MCP\Integrations\Divi\Release\Call;
|
use LIT\MCP\Integrations\Divi\Release\Call;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||||
@@ -25,8 +26,8 @@ defined('ABSPATH') || exit;
|
|||||||
* date_format => select]. DynamicContentRules routes each setting by that type and keeps its fixed
|
* date_format => select]. DynamicContentRules routes each setting by that type and keeps its fixed
|
||||||
* rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28).
|
* rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28).
|
||||||
*
|
*
|
||||||
* - loop_* options never enter the map: Divi offers them only inside loop-enabled modules,
|
* - loop_* options never enter this map: loopFields() keeps them apart, grouped by loop kind (ruling X2), for
|
||||||
* which the tools do not track – DynamicContentRules refuses them ("nur innerhalb von Divi-Schleifen").
|
* a composition inside a Divi loop (LoopScope); DynamicContentRules allows one only in a loop of its kind.
|
||||||
* - The map REPLACES DynamicContentRules' default, so every Placeholders::POST option is added when
|
* - The map REPLACES DynamicContentRules' default, so every Placeholders::POST option is added when
|
||||||
* the registry lacks it, with the settings the placeholders write (DynamicContentRules' default).
|
* the registry lacks it, with the settings the placeholders write (DynamicContentRules' default).
|
||||||
* - Registry unavailable (no Divi, empty, error): null – DynamicContentRules' default map applies.
|
* - Registry unavailable (no Divi, empty, error): null – DynamicContentRules' default map applies.
|
||||||
@@ -38,6 +39,13 @@ class DynamicContentMap implements DiviAdapter
|
|||||||
/** Divi's dynamic-content registry class; listed in manifest(). */
|
/** Divi's dynamic-content registry class; listed in manifest(). */
|
||||||
public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
|
public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
|
||||||
|
|
||||||
|
/** Divi's loop option groups of other kinds than post (DynamicContentOptionTermsGroups.php, …UsersGroups.php). */
|
||||||
|
private const LOOP_GROUPS = [
|
||||||
|
'Loop Terms' => LoopScope::TERM,
|
||||||
|
'Loop Users' => LoopScope::USER,
|
||||||
|
'Loop Menus' => LoopScope::MENU,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.
|
* @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.
|
||||||
*/
|
*/
|
||||||
@@ -64,7 +72,7 @@ class DynamicContentMap implements DiviAdapter
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
|
* The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
|
||||||
* names and types as forPost(), loop_* left out). custom_meta_<key> options a filter registers
|
* names and types as forPost(), loop_* left out – see loopFields()). custom_meta_<key> options a filter registers
|
||||||
* for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so
|
* for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so
|
||||||
* the listing never names them. Only label, group and setting types leave Divi's registry –
|
* the listing never names them. Only label, group and setting types leave Divi's registry –
|
||||||
* no select options or defaults (they list meta keys). Null when the registry is unavailable.
|
* no select options or defaults (they list meta keys). Null when the registry is unavailable.
|
||||||
@@ -95,6 +103,31 @@ class DynamicContentMap implements DiviAdapter
|
|||||||
return $options;
|
return $options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loop_* options of one loop kind (LoopScope::POST, TERM, USER, MENU): option name => [setting key =>
|
||||||
|
* field type], the shape of forPost() – a composition inside a loop of that kind adds them to its map.
|
||||||
|
* Kind (ruling X2): the three meta key options by their name (their groups are translated:
|
||||||
|
* "Loop %s Custom Fields", DynamicContentOptionLoopPostMetaKey.php:225), the others by Divi's group
|
||||||
|
* ("Loop Terms", "Loop Users", "Loop Menus"; every other group – "Loop", "Loop Product" – is post).
|
||||||
|
* ACF repeater options (loop_acf_*) have no kind (X2/X7e). Names are the registry keys – the names
|
||||||
|
* Divi stores and renders (get_options() sets every id to its key). [] when the registry is unavailable.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, string>>
|
||||||
|
*/
|
||||||
|
public function loopFields(string $kind, int $postId = 0): array
|
||||||
|
{
|
||||||
|
$raw = $this->raw($postId) ?? [];
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
foreach ($raw as $name => $option) {
|
||||||
|
if (is_string($name) && is_array($option) && self::loopKind($name, $option) === $kind) {
|
||||||
|
$fields[$name] = self::fields($option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Divi's registry for the post; any filter error is logged (message only) and yields null.
|
* Divi's registry for the post; any filter error is logged (message only) and yields null.
|
||||||
*
|
*
|
||||||
@@ -125,13 +158,7 @@ class DynamicContentMap implements DiviAdapter
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fields = [];
|
$map[$name] = self::fields($option);
|
||||||
foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
|
|
||||||
if (is_string($key) && $key !== '') {
|
|
||||||
$fields[$key] = self::type($key, $field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$map[$name] = $fields;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (Placeholders::POST as $option) {
|
foreach (Placeholders::POST as $option) {
|
||||||
@@ -141,6 +168,39 @@ class DynamicContentMap implements DiviAdapter
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setting key => field type of one registry option.
|
||||||
|
*
|
||||||
|
* @param array<array-key, mixed> $option
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function fields(array $option): array
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
|
||||||
|
if (is_string($key) && $key !== '') {
|
||||||
|
$fields[$key] = self::type($key, $field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop kind of a registry option (see loopFields()); null for other names and ACF repeater options.
|
||||||
|
*
|
||||||
|
* @param array<array-key, mixed> $option
|
||||||
|
*/
|
||||||
|
private static function loopKind(string $name, array $option): ?string
|
||||||
|
{
|
||||||
|
$byName = LoopScope::kindOfOption($name);
|
||||||
|
if ($byName === null || str_contains($name, '_meta_key_')) {
|
||||||
|
return $byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::LOOP_GROUPS[is_string($option['group'] ?? null) ? $option['group'] : ''] ?? LoopScope::POST;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Field type of one setting. before/after are "richtext" whatever Divi declares ("text"): Divi
|
* Field type of one setting. before/after are "richtext" whatever Divi declares ("text"): Divi
|
||||||
* outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's
|
* outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ final class DiviFixtures
|
|||||||
$option = static fn (string $id, string $group, array $fields): array => [
|
$option = static fn (string $id, string $group, array $fields): array => [
|
||||||
'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
|
'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
|
||||||
];
|
];
|
||||||
|
$select = static fn (): array => ['label' => 'x', 'type' => 'select', 'default' => ''];
|
||||||
|
$loop = ['before' => $text(), 'after' => $text(), 'loop_position' => $text()];
|
||||||
|
$date = ['date_format' => $select(), 'custom_date_format' => $text()];
|
||||||
|
$meta = $loop + $date + [
|
||||||
|
'select_loop_meta_key' => $select(),
|
||||||
|
'loop_meta_key' => $text(),
|
||||||
|
'enable_html' => ['label' => 'x', 'type' => 'yes_no_button', 'default' => 'off'],
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]),
|
'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]),
|
||||||
@@ -131,12 +139,32 @@ final class DiviFixtures
|
|||||||
'meta_key' => $text(),
|
'meta_key' => $text(),
|
||||||
'enable_html' => ['type' => 'yes_no_button'],
|
'enable_html' => ['type' => 'yes_no_button'],
|
||||||
]),
|
]),
|
||||||
'loop_post_title' => $option('loop_post_title', 'Loop', [
|
// Loop options as Divi 5.15.1's get_options() returns them (research 4e §2): id = key = stored name.
|
||||||
'before' => $text(), 'after' => $text(), 'loop_position' => $text(),
|
'loop_post_title' => $option('loop_post_title', 'Loop', $loop),
|
||||||
]),
|
'loop_post_date' => $option('loop_post_date', 'Loop', $loop + $date),
|
||||||
'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [
|
'loop_post_featured_image' => $option('loop_post_featured_image', 'Loop', [
|
||||||
'loop_meta_key' => $text(),
|
'loop_position' => $text(), 'thumbnail_size' => $select(),
|
||||||
]),
|
]),
|
||||||
|
'loop_post_meta_key_manual_custom_field' => $option(
|
||||||
|
'loop_post_meta_key_manual_custom_field',
|
||||||
|
'Loop Post Custom Fields',
|
||||||
|
$meta
|
||||||
|
),
|
||||||
|
'loop_product_title' => $option('loop_product_title', 'Loop Product', $loop),
|
||||||
|
'loop_term_name' => $option('loop_term_name', 'Loop Terms', $loop),
|
||||||
|
'loop_term_meta_key_manual_custom_field' => $option(
|
||||||
|
'loop_term_meta_key_manual_custom_field',
|
||||||
|
'Loop Term Custom Fields',
|
||||||
|
$meta
|
||||||
|
),
|
||||||
|
'loop_user_name' => $option('loop_user_name', 'Loop Users', $loop),
|
||||||
|
'loop_user_meta_key_manual_custom_field' => $option(
|
||||||
|
'loop_user_meta_key_manual_custom_field',
|
||||||
|
'Loop User Custom Fields',
|
||||||
|
$meta
|
||||||
|
),
|
||||||
|
'loop_menu_text' => $option('loop_menu_text', 'Loop Menus', $loop),
|
||||||
|
'loop_acf_team|||name' => $option('loop_acf_team|||name', 'Loop ACF team', $loop + ['acf_type' => $text()]),
|
||||||
// Odd entries a third-party filter could add.
|
// Odd entries a third-party filter could add.
|
||||||
'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]),
|
'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]),
|
||||||
'no_fields' => ['id' => 'no_fields', 'type' => 'text'],
|
'no_fields' => ['id' => 'no_fields', 'type' => 'text'],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
|||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
@@ -210,6 +211,88 @@ final class DynamicContentMapTest extends TestCase
|
|||||||
self::assertSame($before, json_encode($after));
|
self::assertSame($before, json_encode($after));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling X2: loop_* options stay, grouped by kind – by group, the three meta options by name. */
|
||||||
|
public function testLoopFieldsGroupTheLoopOptionsByKind(): void
|
||||||
|
{
|
||||||
|
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'loop_post_title', 'loop_post_date', 'loop_post_featured_image',
|
||||||
|
'loop_post_meta_key_manual_custom_field', 'loop_product_title',
|
||||||
|
],
|
||||||
|
array_keys($map->loopFields(LoopScope::POST))
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['loop_term_name', 'loop_term_meta_key_manual_custom_field'],
|
||||||
|
array_keys($map->loopFields(LoopScope::TERM))
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['loop_user_name', 'loop_user_meta_key_manual_custom_field'],
|
||||||
|
array_keys($map->loopFields(LoopScope::USER))
|
||||||
|
);
|
||||||
|
self::assertSame(['loop_menu_text'], array_keys($map->loopFields(LoopScope::MENU)));
|
||||||
|
// ACF repeater rows have no kind (X2/X7e); an unknown kind has no options.
|
||||||
|
self::assertSame([], $map->loopFields('repeater_team'));
|
||||||
|
self::assertSame([], $map->loopFields(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoopFieldsHaveTheShapeOfTheAllowMap(): void
|
||||||
|
{
|
||||||
|
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||||||
|
|
||||||
|
$post = $map->loopFields(LoopScope::POST);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
|
||||||
|
'date_format' => 'select', 'custom_date_format' => 'text',
|
||||||
|
],
|
||||||
|
$post['loop_post_date']
|
||||||
|
);
|
||||||
|
self::assertSame(['loop_position' => 'text', 'thumbnail_size' => 'select'], $post['loop_post_featured_image']);
|
||||||
|
self::assertSame(
|
||||||
|
['select', 'text', 'yes_no_button'],
|
||||||
|
[
|
||||||
|
$post['loop_post_meta_key_manual_custom_field']['select_loop_meta_key'],
|
||||||
|
$post['loop_post_meta_key_manual_custom_field']['loop_meta_key'],
|
||||||
|
$post['loop_post_meta_key_manual_custom_field']['enable_html'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The custom field groups are translated ("Loop %s Custom Fields", esc_html__) – their name decides. */
|
||||||
|
public function testTheMetaOptionsFollowTheirNameNotTheirGroup(): void
|
||||||
|
{
|
||||||
|
$registry = DiviFixtures::dynamicContentRegistry();
|
||||||
|
$registry['loop_user_meta_key_manual_custom_field']['group'] = 'Schleife: Benutzer-Felder';
|
||||||
|
$registry['loop_term_meta_key_manual_custom_field']['group'] = 'Loop';
|
||||||
|
$registry['loop_post_meta_key_manual_custom_field']['group'] = 'Loop Users';
|
||||||
|
$map = new DynamicContentMap(static fn (int $id): array => $registry);
|
||||||
|
|
||||||
|
self::assertArrayHasKey('loop_user_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
|
||||||
|
self::assertArrayHasKey('loop_term_meta_key_manual_custom_field', $map->loopFields(LoopScope::TERM));
|
||||||
|
self::assertArrayHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::POST));
|
||||||
|
self::assertArrayNotHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoopFieldsReadTheRegistryOfThePostAndAreEmptyWithoutIt(): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
$map = new DynamicContentMap(static function (int $postId) use (&$seen): array {
|
||||||
|
$seen[] = $postId;
|
||||||
|
|
||||||
|
return DiviFixtures::dynamicContentRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
$map->loopFields(LoopScope::POST, 42);
|
||||||
|
$map->loopFields(LoopScope::TERM);
|
||||||
|
|
||||||
|
self::assertSame([42, 0], $seen);
|
||||||
|
self::assertSame([], (new DynamicContentMap(static fn (): array => []))->loopFields(LoopScope::POST));
|
||||||
|
self::assertSame([], (new DynamicContentMap())->loopFields(LoopScope::POST), 'without Divi');
|
||||||
|
}
|
||||||
|
|
||||||
private static function assertRejected(callable $call, string $fragment): void
|
private static function assertRejected(callable $call, string $fragment): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user