feat: Pods fields in Theme Builder bodies checked against the template's post types (E12)
Both forms Divi writes are recognized (ruling Q11): custom_meta_<key> and the dialog form post_meta_key + select_meta_key. Choice fields get a note about the stored value, relation fields name IDs (F4). Only templates the user may edit are checked or named; others are counted (Q21 pattern). Warnings only, also in the dry run of a live body. DynamicFields extracted from lit/divi-dynamic-sources. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,20 @@ final class VariableRef
|
||||
return is_array($settings) ? $settings : [];
|
||||
}
|
||||
|
||||
/** The option name of $value when it is exactly one content reference ("custom_meta_role"); null otherwise. */
|
||||
public static function nameOf(string $value): ?string
|
||||
{
|
||||
try {
|
||||
$payload = self::payloadOf($value);
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $payload['value']['name'] ?? null;
|
||||
|
||||
return ($payload['type'] ?? null) === 'content' && is_string($name) ? $name : null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|stdClass $settings */
|
||||
public static function serialize(string $type, string $name, array|stdClass $settings): string
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
|
||||
@@ -178,7 +179,8 @@ final class DiviIntegration implements Integration
|
||||
$this->themeBuilderAccess,
|
||||
new DiviComposition($this->modules, $this->design),
|
||||
new DiviSaver(),
|
||||
$this->log
|
||||
$this->log,
|
||||
new DynamicFields($this->schema)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +193,7 @@ final class DiviIntegration implements Integration
|
||||
$this->design,
|
||||
new DiviComposition($this->modules, $this->design),
|
||||
new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())),
|
||||
new ThemeBuilderEdits($this->themeBuilderAccess)
|
||||
new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -27,24 +25,18 @@ defined('ABSPATH') || exit;
|
||||
*
|
||||
* Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods
|
||||
* table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each
|
||||
* left out with its reason. Hidden fields (security audit M4) are neither offered nor named. The
|
||||
* field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25).
|
||||
* left out with its reason (DynamicFields). Hidden fields (security audit M4) are neither offered nor
|
||||
* named. The field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25).
|
||||
*/
|
||||
final class DiviDynamicSourcesAbility implements Ability
|
||||
{
|
||||
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
|
||||
private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
|
||||
|
||||
private const ID_TYPES = ['relation', 'file'];
|
||||
|
||||
/** Candidates the default sample lookup checks for edit_post. */
|
||||
private const SAMPLE_CANDIDATES = 10;
|
||||
|
||||
/** @var Closure(string): int */
|
||||
private Closure $samplePost;
|
||||
|
||||
/** @var Closure(string): ?string */
|
||||
private Closure $podStorage;
|
||||
private DynamicFields $fields;
|
||||
|
||||
/**
|
||||
* @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none).
|
||||
@@ -59,7 +51,7 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
?Closure $podStorage = null
|
||||
) {
|
||||
$this->samplePost = $samplePost ?? self::defaultSample(...);
|
||||
$this->podStorage = $podStorage ?? self::defaultStorage(...);
|
||||
$this->fields = new DynamicFields($schema, $podStorage);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -182,7 +174,7 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
if ($this->roles->allows('read_dynamic_content_custom_fields')) {
|
||||
[$fields, $omitted] = $this->fields($postType);
|
||||
[$fields, $omitted] = $this->fields->offered($postType);
|
||||
} else {
|
||||
$notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte '
|
||||
. 'Felder lesen“ nicht (Divi → Rollen-Editor).';
|
||||
@@ -215,53 +207,6 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
return $type === 'attachment' || str_starts_with($type, '_') ? null : '{{link:<ID>}}';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields.
|
||||
*/
|
||||
private function fields(string $postType): array
|
||||
{
|
||||
$storage = ($this->podStorage)($postType);
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
foreach ($this->schema->fields($postType) as $field) {
|
||||
if ($field->status === FieldDefinition::HIDDEN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['key' => $field->key, 'label' => $field->label];
|
||||
$reason = self::omission($field, $storage);
|
||||
if ($reason !== null) {
|
||||
$omitted[] = $entry + ['reason' => $reason];
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[] = $entry + [
|
||||
'type' => $field->type,
|
||||
'source' => $field->source,
|
||||
'placeholder' => '{{field:' . $field->key . '}}',
|
||||
];
|
||||
}
|
||||
|
||||
return [$fields, $omitted];
|
||||
}
|
||||
|
||||
private static function omission(FieldDefinition $field, ?string $storage): ?string
|
||||
{
|
||||
return match (true) {
|
||||
! DynamicContentRules::isMetaKey($field->key)
|
||||
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
|
||||
$field->source === 'pods' && $storage === 'table'
|
||||
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
|
||||
in_array($field->type, self::ID_TYPES, true)
|
||||
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||||
$field->type === 'unknown'
|
||||
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
|
||||
! in_array($field->type, self::TEXT_TYPES, true)
|
||||
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The most recently modified entry of the type the current user may edit, else 0. */
|
||||
private static function defaultSample(string $postType): int
|
||||
{
|
||||
@@ -283,19 +228,4 @@ final class DiviDynamicSourcesAbility implements Ability
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static function defaultStorage(string $postType): ?string
|
||||
{
|
||||
if (! function_exists('pods_api')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pod = pods_api()->load_pod(['name' => $postType]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,9 +211,17 @@ final class DiviWriter
|
||||
return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout);
|
||||
}
|
||||
|
||||
// E12: fields in a body checked against the templates' post types – warnings only, also in the dry run.
|
||||
$fieldWarnings = $this->themeBuilder->fieldWarnings($page, $change);
|
||||
foreach ($fieldWarnings as $warning) {
|
||||
$change->warning($warning);
|
||||
}
|
||||
|
||||
$prepared = $this->themeBuilder->prepare($page, $input);
|
||||
if ($prepared instanceof WP_Error) {
|
||||
return $prepared;
|
||||
return $fieldWarnings !== [] && Errors::codeOf($prepared) === 'confirm_required'
|
||||
? VisibleEdits::merged($prepared, 'Hinweise zu Feldern siehe warnings.', ['warnings' => $fieldWarnings])
|
||||
: $prepared;
|
||||
}
|
||||
|
||||
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts');
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The fields of a post type Divi can show as text through dynamic content (answer F4, E12): custom_meta_<key>
|
||||
* reads post meta (F25/F26). Relation/file fields show IDs, Pods table storage is no post meta, protected keys
|
||||
* never (S12), unknown types not (ruling P9) – each omitted with its reason. Choice fields are offered, but
|
||||
* Divi shows their stored value, not the label (stage3b render: "senior") – note(). Hidden fields (security
|
||||
* audit M4) are neither offered nor named. Used by lit/divi-dynamic-sources and the Theme Builder body check.
|
||||
* Not final so abilities can be tested with a mock.
|
||||
*/
|
||||
class DynamicFields
|
||||
{
|
||||
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
|
||||
public const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
|
||||
|
||||
public const ID_TYPES = ['relation', 'file'];
|
||||
|
||||
public const UNKNOWN = 'kein bekanntes Feld dieses Inhaltstyps';
|
||||
|
||||
private const PREFIX = 'custom_meta_';
|
||||
|
||||
/** Divi's option for a meta key typed in by hand (the key is in settings.meta_key). */
|
||||
private const MANUAL = 'custom_meta_manual_custom_field_value';
|
||||
|
||||
/** @var Closure(string): ?string */
|
||||
private Closure $podStorage;
|
||||
|
||||
/** @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null: none. */
|
||||
public function __construct(private ContentSchema $schema, ?Closure $podStorage = null)
|
||||
{
|
||||
$this->podStorage = $podStorage ?? self::defaultStorage(...);
|
||||
}
|
||||
|
||||
/** @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields. */
|
||||
public function offered(string $postType): array
|
||||
{
|
||||
$storage = ($this->podStorage)($postType);
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
foreach ($this->schema->fields($postType) as $field) {
|
||||
if ($field->status === FieldDefinition::HIDDEN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['key' => $field->key, 'label' => $field->label];
|
||||
$reason = self::omission($field, $storage);
|
||||
if ($reason !== null) {
|
||||
$omitted[] = $entry + ['reason' => $reason];
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[] = $entry + [
|
||||
'type' => $field->type,
|
||||
'source' => $field->source,
|
||||
'placeholder' => '{{field:' . $field->key . '}}',
|
||||
];
|
||||
}
|
||||
|
||||
return [$fields, $omitted];
|
||||
}
|
||||
|
||||
/** Why {{field:$key}} would not show as text for $postType; null when it does. */
|
||||
public function reason(string $postType, string $key): ?string
|
||||
{
|
||||
[$fields, $omitted] = $this->offered($postType);
|
||||
foreach ($fields as $field) {
|
||||
if ($field['key'] === $key) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
foreach ($omitted as $field) {
|
||||
if ($field['key'] === $key) {
|
||||
return $field['reason'];
|
||||
}
|
||||
}
|
||||
|
||||
return self::UNKNOWN;
|
||||
}
|
||||
|
||||
/** What to know about an offered field of $postType ($key) that shows other text than expected; else null. */
|
||||
public function note(string $postType, string $key): ?string
|
||||
{
|
||||
[$fields] = $this->offered($postType);
|
||||
foreach ($fields as $field) {
|
||||
if ($field['key'] === $key && $field['type'] === 'choice') {
|
||||
return 'Auswahlfeld – Divi zeigt den gespeicherten Wert, nicht die Beschriftung';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The meta key the content reference $value reads, in both forms Divi resolves (ruling Q11, stage3b README
|
||||
* finding 12; DynamicContentOptionPostMetaKey.php:222-262): the name custom_meta_<key> (the plugin's form)
|
||||
* and the name post_meta_key with select_meta_key "custom_meta_<key>" (Divi's dialog), each also with a key
|
||||
* typed in by hand (custom_meta_manual_custom_field_value or post_meta_key with only meta_key). Null for
|
||||
* anything else. Reads, never validates – writing goes through VariableRef::canonical() (S29/S30).
|
||||
*/
|
||||
public static function metaKeyOf(string $value): ?string
|
||||
{
|
||||
if (VariableRef::typeOf($value) !== 'content') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = VariableRef::nameOf($value);
|
||||
$settings = VariableRef::settingsOf($value);
|
||||
$manual = is_string($settings['meta_key'] ?? null) ? $settings['meta_key'] : '';
|
||||
if ($name === 'post_meta_key') {
|
||||
$selected = $settings['select_meta_key'] ?? '';
|
||||
if (! is_string($selected)) {
|
||||
return null;
|
||||
}
|
||||
if ($selected === '') {
|
||||
return $manual !== '' ? $manual : null;
|
||||
}
|
||||
$name = $selected;
|
||||
}
|
||||
|
||||
if ($name === null || ! str_starts_with($name, self::PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
if ($name === self::MANUAL) {
|
||||
return $manual !== '' ? $manual : null;
|
||||
}
|
||||
|
||||
$key = str_replace(self::PREFIX, '', $name);
|
||||
|
||||
return $key !== '' ? $key : null;
|
||||
}
|
||||
|
||||
private static function omission(FieldDefinition $field, ?string $storage): ?string
|
||||
{
|
||||
return match (true) {
|
||||
! DynamicContentRules::isMetaKey($field->key)
|
||||
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
|
||||
$field->source === 'pods' && $storage === 'table'
|
||||
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
|
||||
in_array($field->type, self::ID_TYPES, true)
|
||||
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||||
$field->type === 'unknown'
|
||||
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
|
||||
! in_array($field->type, self::TEXT_TYPES, true)
|
||||
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function defaultStorage(string $postType): ?string
|
||||
{
|
||||
if (! function_exists('pods_api')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pod = pods_api()->load_pod(['name' => $postType]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,8 @@ final class TbCreateTemplateAbility implements Ability
|
||||
private TemplateConditions $conditions,
|
||||
private DiviComposition $composition,
|
||||
private DiviSaver $saver,
|
||||
private ChangeLog $log
|
||||
private ChangeLog $log,
|
||||
private ?ThemeBuilderEdits $edits = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -286,6 +287,14 @@ final class TbCreateTemplateAbility implements Ability
|
||||
SaveScope::ThemeBuilderLayout
|
||||
)
|
||||
);
|
||||
if ($area === 'body' && $this->edits !== null) {
|
||||
// E12: fields of the new body against the post types of the new conditions – warnings only.
|
||||
$warnings = array_merge($warnings, $this->edits->checkFields(
|
||||
$layout['blocks'],
|
||||
Template::postTypesOf($useOn),
|
||||
'Das neue Body-Layout'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$themeBuilderId = $build->themeBuilder();
|
||||
|
||||
@@ -92,9 +92,18 @@ final class Template
|
||||
|
||||
/** @return list<string> Post types of its singular conditions (singular:post_type:<type>:…). */
|
||||
public function postTypes(): array
|
||||
{
|
||||
return self::postTypesOf($this->useOn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $useOn
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function postTypesOf(array $useOn): array
|
||||
{
|
||||
$types = [];
|
||||
foreach ($this->useOn as $condition) {
|
||||
foreach ($useOn as $condition) {
|
||||
$parts = explode(':', $condition);
|
||||
if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') {
|
||||
$types[] = $parts[2];
|
||||
|
||||
@@ -6,7 +6,10 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use WP_Error;
|
||||
@@ -22,12 +25,17 @@ defined('ABSPATH') || exit;
|
||||
* change (VisibleEdits, ruling Q2): a backup when revisions are off, refused when it could not be faithful (P9).
|
||||
* Layouts of disabled or detached templates save at once. The save scope sets the Divi flags and clears every
|
||||
* page's CSS and the Theme Builder cache.
|
||||
*
|
||||
* Fields in a body (E12, answer F4): the field references of the changed nodes – both forms Divi writes (ruling
|
||||
* Q11) – are checked against the post types of the templates that use the body; warnings only, nothing is
|
||||
* refused. Templates the user may not edit are only counted (ruling Q21 pattern). Read-only analysis.
|
||||
*/
|
||||
final class ThemeBuilderEdits
|
||||
{
|
||||
public function __construct(
|
||||
private ThemeBuilderAccess $access,
|
||||
private VisibleEdits $visible = new VisibleEdits()
|
||||
private VisibleEdits $visible = new VisibleEdits(),
|
||||
private ?DynamicFields $fields = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -99,6 +107,160 @@ final class ThemeBuilderEdits
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* E12: in a body layout, the field references of the changed nodes checked against the post types of the
|
||||
* templates that use the layout (their singular conditions). Only templates the user may edit are named or
|
||||
* checked; others add a count (ruling Q21 pattern). Warnings only – the layout is saved anyway.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function fieldWarnings(DiviPage $page, DiviChange $change): array
|
||||
{
|
||||
if ($this->fields === null || $page->themeBuilderArea() !== 'body') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = self::keysIn($change->touched());
|
||||
if ($keys === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$id = (int) $page->post->ID;
|
||||
$uses = static fn (Template $template): bool => $template->layouts['body']['id'] === $id;
|
||||
$live = $this->access->editableLiveTemplates();
|
||||
$types = [];
|
||||
foreach (array_filter($live['templates'], $uses) as $template) {
|
||||
array_push($types, ...$template->postTypes());
|
||||
}
|
||||
|
||||
$types = array_values(array_unique($types));
|
||||
$hidden = count(array_filter($live['hidden'], $uses));
|
||||
if ($types === [] && $hidden > 0) {
|
||||
return [sprintf(
|
||||
'Body-Layout %d nutzen nur Vorlagen ohne Berechtigung (%d) oder keine mit Bedingung '
|
||||
. '„singular:post_type:…“; ob die Felder %s dort existieren, ist ungeprüft.',
|
||||
$id,
|
||||
$hidden,
|
||||
implode(', ', $keys)
|
||||
)];
|
||||
}
|
||||
|
||||
$warnings = $this->checkKeys($keys, $types, sprintf('Body-Layout %d', $id));
|
||||
if ($hidden > 0) {
|
||||
$warnings[] = sprintf(
|
||||
'Weitere Vorlagen ohne Berechtigung nutzen dieses Layout: %d; ob die Felder %s für deren '
|
||||
. 'Inhaltstypen passen, ist ungeprüft.',
|
||||
$hidden,
|
||||
implode(', ', $keys)
|
||||
);
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field references ({{field:<key>}} = custom_meta_<key>, and Divi's dialog form post_meta_key with
|
||||
* select_meta_key – ruling Q11) in $blocks and their descendants against $postTypes.
|
||||
*
|
||||
* @param list<Block> $blocks
|
||||
* @param list<string> $postTypes
|
||||
* @param string $what Names the layout in the warning ("Das neue Body-Layout").
|
||||
* @return list<string>
|
||||
*/
|
||||
public function checkFields(array $blocks, array $postTypes, string $what): array
|
||||
{
|
||||
if ($this->fields === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = self::keysIn($blocks);
|
||||
|
||||
return $keys === [] ? [] : $this->checkKeys($keys, $postTypes, $what);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $keys
|
||||
* @param list<string> $postTypes
|
||||
* @return list<string>
|
||||
*/
|
||||
private function checkKeys(array $keys, array $postTypes, string $what): array
|
||||
{
|
||||
if ($this->fields === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($postTypes === []) {
|
||||
return [sprintf(
|
||||
'%s gehört zu keiner Vorlage mit Bedingung „singular:post_type:…“; ob die Felder %s dort existieren, '
|
||||
. 'ist ungeprüft (Bedingungen mit lit/tb-assign setzen).',
|
||||
$what,
|
||||
implode(', ', $keys)
|
||||
)];
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
foreach ($keys as $key) {
|
||||
$problems = [];
|
||||
$notes = [];
|
||||
foreach ($postTypes as $type) {
|
||||
$reason = $this->fields->reason($type, $key);
|
||||
if ($reason !== null) {
|
||||
$problems[] = $type . ': ' . $reason;
|
||||
continue;
|
||||
}
|
||||
$note = $this->fields->note($type, $key);
|
||||
if ($note !== null) {
|
||||
$notes[] = $type . ': ' . $note;
|
||||
}
|
||||
}
|
||||
if ($problems !== []) {
|
||||
$warnings[] = sprintf(
|
||||
'Feld „%s“ zeigt Divi womöglich nicht als Text (%s); passende Felder mit lit/divi-dynamic-sources.',
|
||||
$key,
|
||||
implode('; ', array_merge($problems, $notes))
|
||||
);
|
||||
} elseif ($notes !== []) {
|
||||
$warnings[] = sprintf('Feld „%s“: %s.', $key, implode('; ', $notes));
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Block> $blocks
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function keysIn(array $blocks): array
|
||||
{
|
||||
$keys = [];
|
||||
foreach ($blocks as $block) {
|
||||
self::collectKeys($block, $keys);
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/** @param list<string> $keys */
|
||||
private static function collectKeys(Block $block, array &$keys): void
|
||||
{
|
||||
if ($block->isOpaque()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attrs = $block->attrs();
|
||||
array_walk_recursive($attrs, static function (mixed $value) use (&$keys): void {
|
||||
$key = is_string($value) ? DynamicFields::metaKeyOf($value) : null;
|
||||
if ($key !== null) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
});
|
||||
|
||||
foreach ($block->children() as $child) {
|
||||
self::collectKeys($child, $keys);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<Template> $templates */
|
||||
private static function count(array $templates): string
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
|
||||
@@ -26,7 +27,8 @@ final class ThemeBuilderIntegration implements Integration
|
||||
private ThemeBuilderAccess $access,
|
||||
private DiviComposition $composition,
|
||||
private DiviSaver $saver,
|
||||
private ChangeLog $log
|
||||
private ChangeLog $log,
|
||||
private DynamicFields $fields
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -51,7 +53,14 @@ final class ThemeBuilderIntegration implements Integration
|
||||
|
||||
return [
|
||||
new TbListAbility($this->access, $conditions),
|
||||
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
|
||||
new TbCreateTemplateAbility(
|
||||
$this->access,
|
||||
$conditions,
|
||||
$this->composition,
|
||||
$this->saver,
|
||||
$this->log,
|
||||
new ThemeBuilderEdits($this->access, fields: $this->fields)
|
||||
),
|
||||
new TbAssignAbility($this->access, $conditions, $this->log),
|
||||
new TbActivateAbility($this->access, $conditions, $this->log),
|
||||
];
|
||||
|
||||
@@ -519,4 +519,12 @@ final class VariableRefTest extends TestCase
|
||||
$this->expectExceptionMessage('nur in Link- und URL-Feldern');
|
||||
$ref->canonical($link, ComposeFixture::sanitizer());
|
||||
}
|
||||
|
||||
public function testNameOfReadsTheContentOptionOnly(): void
|
||||
{
|
||||
self::assertSame('custom_meta_role', VariableRef::nameOf(self::content('custom_meta_role', '{}')));
|
||||
self::assertNull(VariableRef::nameOf('$variable({"type":"color","value":{"name":"gcid-primary","settings":{}}})$'));
|
||||
self::assertNull(VariableRef::nameOf('Text'));
|
||||
self::assertNull(VariableRef::nameOf('$variable(kaputt)$'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
@@ -48,7 +49,7 @@ final class DiviWriterThemeBuilderTest extends TestCase
|
||||
}
|
||||
|
||||
/** @param list<Template> $templates */
|
||||
private function writer(array $templates, ?VisibleEdits $visible = null): DiviWriter
|
||||
private function writer(array $templates, ?VisibleEdits $visible = null, ?DynamicFields $fields = null): DiviWriter
|
||||
{
|
||||
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates));
|
||||
|
||||
@@ -58,7 +59,7 @@ final class DiviWriterThemeBuilderTest extends TestCase
|
||||
false,
|
||||
ThemeBuilderFixtures::loader($access),
|
||||
null,
|
||||
$visible === null ? new ThemeBuilderEdits($access) : new ThemeBuilderEdits($access, $visible)
|
||||
new ThemeBuilderEdits($access, $visible ?? new VisibleEdits(), $fields)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,4 +193,33 @@ final class DiviWriterThemeBuilderTest extends TestCase
|
||||
self::assertSame(1065, $entry['templates'][0]['id']);
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
private static function unknownFields(): DynamicFields
|
||||
{
|
||||
$fields = Mockery::mock(DynamicFields::class);
|
||||
$fields->allows('reason')->andReturn('kein bekanntes Feld dieses Inhaltstyps');
|
||||
$fields->allows('note')->andReturnNull();
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** E12: an unsuitable field in a body is a warning – saved anyway; the dry run of a live body shows it too. */
|
||||
public function testFieldWarningsInTheResultAndInTheDryRun(): void
|
||||
{
|
||||
$field = ['nodes' => [['type' => 'text', 'content' => '{{field:telefon}}']]];
|
||||
$disabled = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
|
||||
|
||||
$result = (new DiviInsertAbility($this->writer([$disabled], null, self::unknownFields())))
|
||||
->execute($this->insert($this->body, $field));
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertCount(1, $this->saved);
|
||||
self::assertStringContainsString('„telefon“', implode(' ', $result['warnings']));
|
||||
|
||||
$live = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'use_on' => ['singular:post_type:lit_person:all']]);
|
||||
$dry = (new DiviInsertAbility($this->writer([$live], null, self::unknownFields())))
|
||||
->execute($this->insert($this->body, $field));
|
||||
self::assertToolError($dry, 'confirm_required');
|
||||
self::assertStringContainsString('„telefon“', implode(' ', $dry->get_error_data()['warnings']));
|
||||
self::assertCount(1, $this->saved);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DynamicFieldsTest extends TestCase
|
||||
{
|
||||
private static function fields(): DynamicFields
|
||||
{
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('fields')->with('lit_person')->andReturn([
|
||||
new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE),
|
||||
new FieldDefinition('level', 'Stufe', 'choice', 'pods', FieldDefinition::WRITABLE),
|
||||
new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', FieldDefinition::WRITABLE),
|
||||
]);
|
||||
|
||||
return new DynamicFields($schema, static fn (string $type): ?string => 'meta');
|
||||
}
|
||||
|
||||
public function testTextLikeFieldsAreOfferedOthersOmittedWithTheirReason(): void
|
||||
{
|
||||
[$offered, $omitted] = self::fields()->offered('lit_person');
|
||||
|
||||
self::assertSame(['role', 'level'], array_column($offered, 'key'));
|
||||
self::assertSame('{{field:role}}', $offered[0]['placeholder']);
|
||||
self::assertSame(['mentor'], array_column($omitted, 'key'));
|
||||
}
|
||||
|
||||
public function testReasonForOneKey(): void
|
||||
{
|
||||
self::assertNull(self::fields()->reason('lit_person', 'role'));
|
||||
self::assertStringContainsString('nur IDs', (string) self::fields()->reason('lit_person', 'mentor'));
|
||||
self::assertSame('kein bekanntes Feld dieses Inhaltstyps', self::fields()->reason('lit_person', 'telefon'));
|
||||
}
|
||||
|
||||
/** Answer F4 / stage3b render: a choice field shows its stored value ("senior"), not the label. */
|
||||
public function testChoiceFieldsGetANoteAboutTheStoredValue(): void
|
||||
{
|
||||
self::assertStringContainsString('gespeicherten Wert', (string) self::fields()->note('lit_person', 'level'));
|
||||
self::assertNull(self::fields()->note('lit_person', 'role'));
|
||||
self::assertNull(self::fields()->note('lit_person', 'mentor'));
|
||||
self::assertNull(self::fields()->note('lit_person', 'telefon'));
|
||||
}
|
||||
|
||||
private static function content(string $name, array $settings): string
|
||||
{
|
||||
return VariableRef::serialize('content', $name, $settings);
|
||||
}
|
||||
|
||||
/** Ruling Q11 / stage3b finding 12: both forms Divi resolves (DynamicContentOptionPostMetaKey.php:222-262). */
|
||||
public function testMetaKeyOfReadsBothFormsDiviWrites(): void
|
||||
{
|
||||
self::assertSame('role', DynamicFields::metaKeyOf(self::content('custom_meta_role', ['before' => ''])));
|
||||
self::assertSame('role', DynamicFields::metaKeyOf(self::content('post_meta_key', [
|
||||
'before' => '',
|
||||
'after' => '',
|
||||
'select_meta_key' => 'custom_meta_role',
|
||||
'meta_key' => '',
|
||||
'date_format' => 'default',
|
||||
'custom_date_format' => '',
|
||||
'enable_html' => 'off',
|
||||
])));
|
||||
self::assertSame('telefon', DynamicFields::metaKeyOf(self::content('post_meta_key', ['meta_key' => 'telefon'])));
|
||||
self::assertSame('telefon', DynamicFields::metaKeyOf(self::content('post_meta_key', [
|
||||
'select_meta_key' => 'custom_meta_manual_custom_field_value',
|
||||
'meta_key' => 'telefon',
|
||||
])));
|
||||
self::assertSame('telefon', DynamicFields::metaKeyOf(self::content(
|
||||
'custom_meta_manual_custom_field_value',
|
||||
['meta_key' => 'telefon']
|
||||
)));
|
||||
}
|
||||
|
||||
public function testMetaKeyOfIgnoresEverythingElse(): void
|
||||
{
|
||||
self::assertNull(DynamicFields::metaKeyOf(self::content('post_title', ['before' => ''])));
|
||||
self::assertNull(DynamicFields::metaKeyOf(self::content('post_meta_key', ['meta_key' => ''])));
|
||||
self::assertNull(DynamicFields::metaKeyOf(self::content('post_meta_key', ['select_meta_key' => 'role'])));
|
||||
self::assertNull(DynamicFields::metaKeyOf(self::content('custom_meta_', [])));
|
||||
self::assertNull(DynamicFields::metaKeyOf(
|
||||
'$variable({"type":"color","value":{"name":"custom_meta_role","settings":{}}})$'
|
||||
));
|
||||
self::assertNull(DynamicFields::metaKeyOf('custom_meta_role'));
|
||||
}
|
||||
|
||||
/** The real builder output (stage3b body 2391) names role, level and mentor. */
|
||||
public function testMetaKeysOfTheRealBuilderBody(): void
|
||||
{
|
||||
$content = DiviFixtures::markup('stage3b/tb-body--person-body.html');
|
||||
preg_match_all('/"value":"(\$variable\(.*?\)\$)"/', $content, $matches);
|
||||
$keys = [];
|
||||
foreach ($matches[1] as $value) {
|
||||
$key = DynamicFields::metaKeyOf((string) json_decode('"' . $value . '"'));
|
||||
if ($key !== null) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
self::assertSame(['role', 'level', 'mentor'], $keys);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
/** The body layout the real builder wrote with Pods fields (stage3b) stays editable and byte-identical. */
|
||||
final class Stage3bBodyEditTest extends TestCase
|
||||
{
|
||||
public function testChangingALabelKeepsEveryOtherByteIncludingThePodsReferences(): void
|
||||
{
|
||||
$content = DiviFixtures::markup('stage3b/tb-body--person-body.html');
|
||||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY, ['post_content' => $content]);
|
||||
LibraryFixtures::given([$body]);
|
||||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||||
$saved = [];
|
||||
$failures = [];
|
||||
$access = ThemeBuilderFixtures::access();
|
||||
$writer = DiviWriterFixture::writer(
|
||||
$saved,
|
||||
$failures,
|
||||
false,
|
||||
ThemeBuilderFixtures::loader($access),
|
||||
null,
|
||||
new ThemeBuilderEdits($access)
|
||||
);
|
||||
|
||||
$result = (new DiviUpdateAbility($writer))->execute([
|
||||
'post_id' => 1062,
|
||||
'tree_rev' => Rev::of($content),
|
||||
'changes' => [['id' => 's0.r0.c0.m0', 'label' => 'Person Name']],
|
||||
]);
|
||||
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertSame(str_replace('"value":"Person Titel"', '"value":"Person Name"', $content), $saved[0]);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbCreateTemplateAbility;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||
@@ -86,10 +88,22 @@ final class TbCreateTemplateAbilityTest extends TestCase
|
||||
new TemplateConditions($store),
|
||||
DiviWriterFixture::composition(DiviWriterFixture::designReader()),
|
||||
$saver,
|
||||
$log ?? DiviWriterFixture::log()
|
||||
$log ?? DiviWriterFixture::log(),
|
||||
new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, $store), fields: self::personFields())
|
||||
);
|
||||
}
|
||||
|
||||
private static function personFields(): DynamicFields
|
||||
{
|
||||
$fields = Mockery::mock(DynamicFields::class);
|
||||
$fields->allows('reason')->andReturnUsing(
|
||||
static fn (string $type, string $key): ?string => $key === 'role' ? null : 'kein bekanntes Feld dieses Inhaltstyps'
|
||||
);
|
||||
$fields->allows('note')->andReturnNull();
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function input(array $extra = []): array
|
||||
{
|
||||
@@ -441,4 +455,20 @@ final class TbCreateTemplateAbilityTest extends TestCase
|
||||
self::assertToolError($result, 'validation_failed');
|
||||
self::assertArrayHasKey('header.source', self::errorFields($result));
|
||||
}
|
||||
|
||||
public function testAnUnknownFieldInTheNewBodyIsAWarning(): void
|
||||
{
|
||||
$store = self::store();
|
||||
self::expectCreation($store);
|
||||
$store->shouldReceive('attach')->once();
|
||||
$store->shouldReceive('clearCaches')->once();
|
||||
$input = self::input();
|
||||
$input['body']['nodes'][0]['modules'][] = ['type' => 'text', 'content' => '{{field:telefon}}'];
|
||||
|
||||
$result = $this->ability($store)->execute($input);
|
||||
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„telefon“')));
|
||||
self::assertSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„role“')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
@@ -15,6 +19,7 @@ use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
final class ThemeBuilderEditsTest extends TestCase
|
||||
@@ -184,4 +189,147 @@ final class ThemeBuilderEditsTest extends TestCase
|
||||
|
||||
self::assertSame(0, $this->backups);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $settings */
|
||||
private static function textWith(string $option, array $settings = ['before' => '', 'after' => '']): Block
|
||||
{
|
||||
return Block::create('divi/text', ['content' => ['innerContent' => ['desktop' => [
|
||||
'value' => VariableRef::serialize('content', $option, $settings),
|
||||
]]]]);
|
||||
}
|
||||
|
||||
/** Divi's dialog form of a Pods field (stage3b README finding 12, ruling Q11). */
|
||||
private static function dialogText(string $key): Block
|
||||
{
|
||||
return self::textWith('post_meta_key', [
|
||||
'before' => '',
|
||||
'after' => '',
|
||||
'select_meta_key' => 'custom_meta_' . $key,
|
||||
'meta_key' => '',
|
||||
'date_format' => 'default',
|
||||
'custom_date_format' => '',
|
||||
'enable_html' => 'off',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var list<array{string, string}> */
|
||||
private array $asked = [];
|
||||
|
||||
private function fields(): DynamicFields
|
||||
{
|
||||
$fields = Mockery::mock(DynamicFields::class);
|
||||
$fields->allows('reason')->andReturnUsing(function (string $type, string $key): ?string {
|
||||
$this->asked[] = [$type, $key];
|
||||
|
||||
return match ($key) {
|
||||
'role', 'level' => null,
|
||||
'mentor' => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||||
default => 'kein bekanntes Feld dieses Inhaltstyps',
|
||||
};
|
||||
});
|
||||
$fields->allows('note')->andReturnUsing(static fn (string $type, string $key): ?string => $key === 'level'
|
||||
? 'Auswahlfeld – Divi zeigt den gespeicherten Wert, nicht die Beschriftung'
|
||||
: null);
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @param list<Template> $templates */
|
||||
private function checking(array $templates): ThemeBuilderEdits
|
||||
{
|
||||
return new ThemeBuilderEdits(
|
||||
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)),
|
||||
fields: $this->fields()
|
||||
);
|
||||
}
|
||||
|
||||
/** E12 / answer F4: fields are checked against the post types of the templates that use the body. */
|
||||
public function testFieldWarningsNameUnsuitableFieldsOfTheTemplatesPostTypes(): void
|
||||
{
|
||||
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
|
||||
$change = (new DiviChange('2 Divi-Knoten eingefügt'))
|
||||
->record(self::textWith('custom_meta_role'), 'inserted')
|
||||
->record(self::textWith('custom_meta_mentor'), 'inserted');
|
||||
|
||||
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
|
||||
|
||||
self::assertCount(1, $warnings);
|
||||
self::assertStringContainsString('„mentor“', $warnings[0]);
|
||||
self::assertStringContainsString('lit_person: Beziehungs', $warnings[0]);
|
||||
self::assertSame([], $edits->fieldWarnings(self::page(ThemeBuilderFixtures::HEADER, 'header'), $change));
|
||||
}
|
||||
|
||||
/** Ruling Q11: Divi's dialog form (post_meta_key + select_meta_key) is checked like custom_meta_<key>. */
|
||||
public function testTheDialogFormIsCheckedToo(): void
|
||||
{
|
||||
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
|
||||
$change = (new DiviChange('x'))
|
||||
->record(self::dialogText('role'), 'changed')
|
||||
->record(self::dialogText('telefon'), 'changed');
|
||||
|
||||
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
|
||||
|
||||
self::assertCount(1, $warnings);
|
||||
self::assertStringContainsString('„telefon“', $warnings[0]);
|
||||
self::assertSame([['lit_person', 'role'], ['lit_person', 'telefon']], $this->asked);
|
||||
}
|
||||
|
||||
/** Answer F4: a choice field shows its stored value – said once per field, with the post type. */
|
||||
public function testChoiceFieldsNameTheStoredValue(): void
|
||||
{
|
||||
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
|
||||
$change = (new DiviChange('x'))->record(self::dialogText('level'), 'changed');
|
||||
|
||||
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
|
||||
|
||||
self::assertCount(1, $warnings);
|
||||
self::assertStringContainsString('„level“', $warnings[0]);
|
||||
self::assertStringContainsString('lit_person: Auswahlfeld – Divi zeigt den gespeicherten Wert', $warnings[0]);
|
||||
}
|
||||
|
||||
public function testWithoutAPostTypeConditionTheFieldsAreUnchecked(): void
|
||||
{
|
||||
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['homepage']])]);
|
||||
$change = (new DiviChange('x'))->record(self::textWith('custom_meta_role'), 'inserted');
|
||||
|
||||
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
|
||||
|
||||
self::assertCount(1, $warnings);
|
||||
self::assertStringContainsString('ungeprüft', $warnings[0]);
|
||||
self::assertSame([], $this->asked);
|
||||
}
|
||||
|
||||
public function testNodesWithoutFieldsOrWithoutAFieldSourceGiveNoWarning(): void
|
||||
{
|
||||
$template = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
|
||||
$plain = (new DiviChange('x'))->record(self::textWith('post_title'), 'inserted');
|
||||
self::assertSame([], $this->checking([$template])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $plain));
|
||||
|
||||
$fields = (new DiviChange('x'))->record(self::textWith('custom_meta_telefon'), 'inserted');
|
||||
self::assertSame([], self::edits([$template])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $fields));
|
||||
}
|
||||
|
||||
/** Ruling Q21 pattern: templates the user may not edit are counted, never their post types. */
|
||||
public function testTemplatesTheUserMayNotEditLeakNoPostType(): void
|
||||
{
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap, mixed $id = null): bool => $id !== 1067);
|
||||
$mine = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
|
||||
$theirs = ThemeBuilderFixtures::template(1067, ['use_on' => ['singular:post_type:geheim:all']]);
|
||||
$change = (new DiviChange('x'))->record(self::textWith('custom_meta_telefon'), 'inserted');
|
||||
|
||||
$warnings = $this->checking([$mine, $theirs])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
|
||||
|
||||
self::assertSame([['lit_person', 'telefon']], $this->asked);
|
||||
self::assertStringNotContainsString('geheim', implode(' ', $warnings));
|
||||
self::assertStringNotContainsString('1067', implode(' ', $warnings));
|
||||
self::assertStringContainsString('ohne Berechtigung', implode(' ', $warnings));
|
||||
|
||||
$this->asked = [];
|
||||
$only = $this->checking([$theirs])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
|
||||
self::assertSame([], $this->asked);
|
||||
self::assertCount(1, $only);
|
||||
self::assertStringContainsString('ungeprüft', $only[0]);
|
||||
self::assertStringContainsString('ohne Berechtigung', $only[0]);
|
||||
self::assertStringNotContainsString('geheim', $only[0]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user