feat: lit/divi-presets-set – Presets anlegen und ändern mit Probelauf und Snapshot
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
341782a34f
commit
b3c3e69a53
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Who may change the design system (spec §10): presets need edit_theme_options and Divi's role right "Visual Builder
|
||||
* verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1").
|
||||
*/
|
||||
final class DesignRights
|
||||
{
|
||||
public static function presets(DesignSystemWriter $writer): ?WP_Error
|
||||
{
|
||||
if (! current_user_can('edit_theme_options')) {
|
||||
return Errors::forbidden(
|
||||
'Presets ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).'
|
||||
);
|
||||
}
|
||||
if (! $writer->canUseVisualBuilder()) {
|
||||
return Errors::forbidden(
|
||||
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine Presets.',
|
||||
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/divi-presets-set (spec §6.3, G7): creates and changes module and option group presets – from the short and full
|
||||
* form, from a module of a page (from_node), with nested group presets and as the default preset – through the
|
||||
* change pipeline (dry run, design_rev, snapshot, Divi's sync, change log). New names must be free within the
|
||||
* module/group (A5). The dry run lists the posts that use a changed preset or reach a new default.
|
||||
*/
|
||||
final class DiviPresetsSetAbility implements Ability
|
||||
{
|
||||
public const MAX_ITEMS = 20;
|
||||
|
||||
private const NAME_MAX = 100;
|
||||
|
||||
private const NAME_RULE = 'Name: 1–100 Zeichen, ohne HTML';
|
||||
|
||||
private const GROUP_DEFAULT_WARNING = 'Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser '
|
||||
. 'Gruppe ohne eigenes Gruppen-Preset; diese Seiten sind nicht aufgelistet.';
|
||||
|
||||
/** @param Closure(): PresetComposer $composer */
|
||||
public function __construct(
|
||||
private DesignSystemWriter $writer,
|
||||
private DesignChange $change,
|
||||
private GlobalUsages $usages,
|
||||
private DiviPageLoader $loader,
|
||||
private Closure $composer
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/divi-presets-set';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Divi-Presets anlegen und ändern';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Legt Modul-Presets (module) und Gruppen-Presets (group) an oder ändert sie (preset = Name oder ID). '
|
||||
. 'Einstellungen in attrs (Vollform wie bei lit-divi-update, mit Platzhaltern {{color:…}}/{{var:…}}) oder '
|
||||
. 'aus einem Modul einer Seite (from_node: post_id + Pfad-ID); nur Gestaltung, keine Texte oder Links. '
|
||||
. 'group_presets verschachtelt Gruppen-Presets, make_default macht das Preset zum Standard. Wirkt auf alle '
|
||||
. 'Seiten: zuerst Probelauf (diff, betroffene Seiten, design_rev), nach Rückfrage beim '
|
||||
. 'Nutzer confirm: true mit design_rev. Vorher wird ein Snapshot angelegt (lit/divi-design-restore).';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'items' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'maxItems' => self::MAX_ITEMS,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'module' => ['type' => 'string'],
|
||||
'group' => ['type' => 'string'],
|
||||
'preset' => ['type' => 'string', 'description' => 'Zu änderndes Preset (Name oder ID).'],
|
||||
'name' => ['type' => 'string', 'description' => 'Name eines neuen Presets.'],
|
||||
'rename' => ['type' => 'string'],
|
||||
'set' => ['type' => 'object'],
|
||||
'attrs' => ['type' => 'object'],
|
||||
'from_node' => [
|
||||
'type' => 'object',
|
||||
'properties' => ['post_id' => ['type' => 'integer'], 'id' => ['type' => 'string']],
|
||||
'required' => ['post_id', 'id'],
|
||||
],
|
||||
'source_module' => ['type' => 'string', 'description' => 'Neues Gruppen-Preset: Modul.'],
|
||||
'group_id' => ['type' => 'string'],
|
||||
'group_presets' => ['type' => 'object'],
|
||||
'make_default' => ['type' => 'boolean'],
|
||||
],
|
||||
],
|
||||
],
|
||||
] + DesignChange::inputProperties(),
|
||||
'required' => ['items'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return DesignChange::outputSchema();
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::destructive(false);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return DesignRights::presets($this->writer) ?? true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = DesignRights::presets($this->writer);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
$items = $input['items'] ?? null;
|
||||
if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) {
|
||||
return Errors::invalidInput(
|
||||
'items fehlt oder ist zu lang; nichts wurde gespeichert.',
|
||||
['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->change->run(
|
||||
$this->name(),
|
||||
new PresetArea($this->writer),
|
||||
$input,
|
||||
fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<mixed> $items */
|
||||
private function plan(PresetBook $before, array $items): DesignPlan|WP_Error
|
||||
{
|
||||
$composer = ($this->composer)();
|
||||
$book = $before;
|
||||
$errors = [];
|
||||
$invalid = [];
|
||||
$targets = [];
|
||||
$warnings = [];
|
||||
foreach ($items as $index => $item) {
|
||||
$field = sprintf('items[%d]', $index);
|
||||
if (! is_array($item)) {
|
||||
$errors[$field] = 'Objekt erwartet';
|
||||
continue;
|
||||
}
|
||||
$prepared = $this->prepare($book, $item, $field, $errors);
|
||||
if ($prepared === null) {
|
||||
continue;
|
||||
}
|
||||
[$scope, $existing, $source] = $prepared;
|
||||
$result = $composer->compose($book, $scope, $existing, $item, $field, $source);
|
||||
$invalid = array_merge($invalid, $result['invalid_paths']);
|
||||
if ($result['errors'] !== [] || $result['invalid_paths'] !== []) {
|
||||
$errors += $result['errors'];
|
||||
continue;
|
||||
}
|
||||
$id = (string) $result['item']['id'];
|
||||
$book = $book->with($scope, $result['item']);
|
||||
if (($item['make_default'] ?? null) === true) {
|
||||
$book = $book->withDefault($scope, $id);
|
||||
if ($scope->kind === PresetScope::GROUP) {
|
||||
$warnings[] = self::GROUP_DEFAULT_WARNING;
|
||||
}
|
||||
} elseif (($item['make_default'] ?? null) === false && $book->defaultId($scope) === $id) {
|
||||
$book = $book->withDefault($scope, '');
|
||||
}
|
||||
if ($existing !== null || $book->defaultId($scope) === $id) {
|
||||
$targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id
|
||||
|| $book->defaultId($scope) === $id];
|
||||
}
|
||||
}
|
||||
|
||||
if ($invalid !== []) {
|
||||
return Errors::invalidAttrPath($errors);
|
||||
}
|
||||
if ($errors !== []) {
|
||||
return Errors::invalidInput('Ungültige Preset-Angaben; nichts wurde gespeichert.', $errors);
|
||||
}
|
||||
|
||||
$diff = PresetDiff::between($before, $book);
|
||||
|
||||
return new DesignPlan(
|
||||
$book->data(),
|
||||
$diff,
|
||||
PresetDiff::summary($diff),
|
||||
$targets === [] ? null : $this->usages->reportFor(new PresetUsageQuery($targets)),
|
||||
array_values(array_unique($warnings))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope, preset to change and source node of one item; null after noting the problem in $errors.
|
||||
*
|
||||
* @param array<array-key, mixed> $item Names are replaced by their cleaned form.
|
||||
* @param array<string, string> $errors
|
||||
* @return array{PresetScope, array<string, mixed>|null, Block|null}|null
|
||||
*/
|
||||
private function prepare(PresetBook $book, array &$item, string $field, array &$errors): ?array
|
||||
{
|
||||
try {
|
||||
$scope = PresetScope::fromInput($item, $field);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
[$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => ''];
|
||||
$errors[$path] = $problem;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$existing = null;
|
||||
try {
|
||||
if (is_string($item['preset'] ?? null)) {
|
||||
$existing = $book->find($scope, $item['preset']);
|
||||
if ($existing === null) {
|
||||
$errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $item['preset']);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$field . '.preset'] = $e->getMessage();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['name' => $existing === null, 'rename' => $existing !== null] as $key => $applies) {
|
||||
if (! $applies || ! is_string($item[$key] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$name = $this->cleanName($item[$key]);
|
||||
if ($name === null) {
|
||||
$errors[$field . '.' . $key] = self::NAME_RULE;
|
||||
|
||||
return null;
|
||||
}
|
||||
$item[$key] = $name;
|
||||
$taken = array_diff($book->idsNamed($scope, $name), [(string) ($existing['id'] ?? '')]);
|
||||
if ($taken !== []) {
|
||||
$errors[$field . '.' . $key] = sprintf(
|
||||
'Name schon vergeben (%s); zum Ändern preset angeben oder einen anderen Namen wählen',
|
||||
implode(', ', $taken)
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$source = $this->source($scope, $item, $field, $errors);
|
||||
if ($source === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$scope, $existing, $source];
|
||||
}
|
||||
|
||||
/** Plain text of 1 to 100 characters, or null (ruling: preset names). */
|
||||
private function cleanName(string $name): ?string
|
||||
{
|
||||
$clean = sanitize_text_field($name);
|
||||
$length = function_exists('mb_strlen') ? mb_strlen($clean) : strlen($clean);
|
||||
|
||||
return $clean === '' || $length > self::NAME_MAX || strlen($name) > self::NAME_MAX * 4 ? null : $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $item
|
||||
* @param array<string, string> $errors
|
||||
*/
|
||||
private function source(PresetScope $scope, array $item, string $field, array &$errors): Block|false|null
|
||||
{
|
||||
$from = $item['from_node'] ?? null;
|
||||
if ($from === null) {
|
||||
return null;
|
||||
}
|
||||
if (! is_array($from) || ! is_string($from['id'] ?? null)) {
|
||||
$errors[$field . '.from_node'] = 'post_id und id (Pfad-ID aus lit-divi-outline) erwartet';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$page = $this->loader->load($from['post_id'] ?? null, false);
|
||||
if ($page instanceof WP_Error) {
|
||||
$errors[$field . '.from_node.post_id'] = Errors::clientMessage($page);
|
||||
|
||||
return false;
|
||||
}
|
||||
$block = $page->index->find($from['id']);
|
||||
if ($block === null) {
|
||||
$errors[$field . '.from_node.id'] = sprintf('unbekannte Pfad-ID %s (lit-divi-outline)', $from['id']);
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($scope->kind === PresetScope::MODULE && $block->name() !== $scope->name) {
|
||||
$errors[$field . '.from_node.id'] = sprintf(
|
||||
'%s ist %s, nicht %s',
|
||||
$from['id'],
|
||||
$block->name(),
|
||||
$scope->name
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,30 @@ final class PresetDiff
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* One German sentence for the dry run and the change log (spec §4 step 6).
|
||||
*
|
||||
* @param list<array<string, mixed>> $diff
|
||||
*/
|
||||
public static function summary(array $diff): string
|
||||
{
|
||||
$count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff));
|
||||
$parts = array_filter([
|
||||
isset($count['added']) ? $count['added'] . ' angelegt' : null,
|
||||
isset($count['changed']) ? $count['changed'] . ' geändert' : null,
|
||||
isset($count['removed']) ? $count['removed'] . ' gelöscht' : null,
|
||||
]);
|
||||
$defaults = array_filter([
|
||||
isset($count['default_set']) ? $count['default_set'] . '× gesetzt' : null,
|
||||
isset($count['default_cleared']) ? $count['default_cleared'] . '× entfernt' : null,
|
||||
]);
|
||||
if ($defaults !== []) {
|
||||
$parts[] = 'Standard ' . implode(', ', $defaults);
|
||||
}
|
||||
|
||||
return 'Presets: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $was
|
||||
* @param array<string, array<string, mixed>> $is
|
||||
|
||||
@@ -10,7 +10,11 @@ use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
|
||||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||||
@@ -155,6 +159,13 @@ final class DiviIntegration implements Integration
|
||||
new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable),
|
||||
new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
|
||||
new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),
|
||||
new DiviPresetsSetAbility(
|
||||
$this->designWriter,
|
||||
$this->designChange(),
|
||||
$this->globalUsages(),
|
||||
$loader,
|
||||
$this->presetComposer(...)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -217,6 +228,25 @@ final class DiviIntegration implements Integration
|
||||
);
|
||||
}
|
||||
|
||||
private function designChange(): DesignChange
|
||||
{
|
||||
return new DesignChange(new SnapshotStore(), $this->log);
|
||||
}
|
||||
|
||||
private function presetComposer(): PresetComposer
|
||||
{
|
||||
$composition = new DiviComposition($this->modules, $this->design);
|
||||
|
||||
return new PresetComposer(
|
||||
$composition->composer(),
|
||||
$composition->storedGuard(),
|
||||
$this->designWriter->presetAttrs(...),
|
||||
$this->designWriter->groupsOf(...),
|
||||
$this->modules->builderVersion(),
|
||||
static fn (): int => (int) floor(microtime(true) * 1000)
|
||||
);
|
||||
}
|
||||
|
||||
private function loader(): DiviPageLoader
|
||||
{
|
||||
return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignRights;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DesignRightsTest extends TestCase
|
||||
{
|
||||
public function testPresetsNeedEditThemeOptionsAndTheVisualBuilder(): void
|
||||
{
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$writer->allows('canUseVisualBuilder')->andReturn(true, false);
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
|
||||
|
||||
self::assertNull(DesignRights::presets($writer));
|
||||
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
|
||||
self::assertStringContainsString('Visual Builder', $refusal['message']);
|
||||
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
|
||||
self::assertStringContainsString('edit_theme_options', $refusal['message']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviPresetsSetAbilityTest extends TestCase
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
private array $stored;
|
||||
|
||||
private SnapshotStore $snapshots;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->stored = PresetBookTest::stored();
|
||||
$this->snapshots = Mockery::mock(SnapshotStore::class);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
}
|
||||
|
||||
private function ability(bool $unfiltered = true, ?GlobalUsages $usages = null, ?DiviPageLoader $loader = null, ?DesignSystemWriter $writer = null): DiviPresetsSetAbility
|
||||
{
|
||||
$writer ??= $this->savingWriter();
|
||||
$usages ??= Mockery::mock(GlobalUsages::class);
|
||||
$usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false));
|
||||
return $this->build($writer, $usages, $loader, $unfiltered);
|
||||
}
|
||||
|
||||
private function savingWriter(): DesignSystemWriter
|
||||
{
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$writer->allows('presets')->andReturnUsing(fn (): array => $this->stored);
|
||||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||||
$writer->allows('savePresets')->andReturnUsing(function (array $rest, bool $mayRemove): array {
|
||||
$data = ['module' => [], 'group' => []];
|
||||
foreach ($rest as $kind => $records) {
|
||||
foreach ($records as $record) {
|
||||
$first = $record['items'][0] ?? null;
|
||||
$name = $kind === 'group' ? ($first['groupName'] ?? 'x') : ($first['moduleName'] ?? 'x');
|
||||
$data[$kind][$name] = ['default' => $record['default'], 'items' => array_column($record['items'], null, 'id')];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->stored = $data;
|
||||
});
|
||||
|
||||
return $writer;
|
||||
}
|
||||
|
||||
private function build(DesignSystemWriter $writer, GlobalUsages $usages, ?DiviPageLoader $loader, bool $unfiltered): DiviPresetsSetAbility
|
||||
{
|
||||
$composer = static fn (): PresetComposer => new PresetComposer(
|
||||
ComposeFixture::composer($unfiltered),
|
||||
new StoredNodeGuard(ComposeFixture::cleaner($unfiltered), ComposeFixture::references(),
|
||||
ComposeFixture::sanitizer($unfiltered)),
|
||||
static fn (string $module, array $attrs): array => ['all' => array_map(
|
||||
static fn (mixed $v): mixed => is_array($v) ? array_diff_key($v, ['innerContent' => 1]) : $v,
|
||||
$attrs
|
||||
), 'style' => [], 'render' => []],
|
||||
static fn (string $module): array => ['module.decoration.spacing' => 'divi/spacing'],
|
||||
'5.15.1',
|
||||
static fn (): int => 1
|
||||
);
|
||||
|
||||
return new DiviPresetsSetAbility(
|
||||
$writer,
|
||||
new DesignChange($this->snapshots, new FakeChangeLog(5)),
|
||||
$usages,
|
||||
$loader ?? new DiviPageLoader(DiviFixtures::schema()),
|
||||
$composer
|
||||
);
|
||||
}
|
||||
|
||||
private static function newButton(array $extra = []): array
|
||||
{
|
||||
return ['items' => [$extra + ['module' => 'button', 'name' => 'CTA dunkel',
|
||||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#111111']]]]]];
|
||||
}
|
||||
|
||||
public function testDryRunThenConfirmCreatesThePreset(): void
|
||||
{
|
||||
$ability = $this->ability();
|
||||
|
||||
$dry = self::assertToolError($ability->execute(self::newButton()), 'confirm_required');
|
||||
self::assertSame('added', $dry['diff'][0]['action']);
|
||||
self::assertStringContainsString('Presets: 1 angelegt.', $dry['message']);
|
||||
|
||||
$this->snapshots->expects('save')->andReturn('presets-1');
|
||||
$this->snapshots->expects('attachLog');
|
||||
$result = $ability->execute(self::newButton() + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertSame('presets-1', $result['snapshot_id']);
|
||||
$names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name');
|
||||
self::assertContains('CTA dunkel', $names);
|
||||
}
|
||||
|
||||
public function testExistingNameIsRefusedForANewPreset(): void
|
||||
{
|
||||
$result = self::assertToolError($this->ability()->execute(['items' => [
|
||||
['module' => 'button', 'name' => 'CTA primär'],
|
||||
]]), 'validation_failed');
|
||||
|
||||
self::assertStringContainsString('aaaaaaaaaa', $result['fields']['items[0].name']);
|
||||
}
|
||||
|
||||
public function testChangingAndMakingDefault(): void
|
||||
{
|
||||
$dry = self::assertToolError($this->ability()->execute(['items' => [[
|
||||
'module' => 'button', 'preset' => 'CTA primär', 'make_default' => true,
|
||||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]],
|
||||
]]]), 'confirm_required');
|
||||
|
||||
self::assertSame(['changed', 'default_set'], array_column($dry['diff'], 'action'));
|
||||
}
|
||||
|
||||
public function testUnknownPresetAndBadItemsAreCollected(): void
|
||||
{
|
||||
$result = self::assertToolError($this->ability()->execute(['items' => [
|
||||
['module' => 'button', 'preset' => 'Fehlt'],
|
||||
['name' => 'ohne Modul'],
|
||||
]]), 'validation_failed');
|
||||
|
||||
self::assertArrayHasKey('items[0].preset', $result['fields']);
|
||||
self::assertArrayHasKey('items[1]', $result['fields']);
|
||||
}
|
||||
|
||||
public function testEditorWithoutUnfilteredHtmlIsRefusedForRawCss(): void
|
||||
{
|
||||
$result = $this->ability(false)->execute(['items' => [[
|
||||
'module' => 'button', 'name' => 'Mit CSS',
|
||||
'attrs' => ['css' => ['desktop' => ['mainElement' => 'color:red']]],
|
||||
]]]);
|
||||
|
||||
self::assertNotTrue(is_array($result) && ($result['ok'] ?? false));
|
||||
self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']);
|
||||
}
|
||||
|
||||
public function testFromNodeReadsThePage(): void
|
||||
{
|
||||
$content = '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||
. '<!-- wp:divi/button {"button":{"decoration":{"background":{"desktop":{"value":{"color":"#abcdef"}}}}}} /-->'
|
||||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
|
||||
DiviFixtures::givenPost($content);
|
||||
$loader = new DiviPageLoader(DiviFixtures::schema());
|
||||
|
||||
$dry = self::assertToolError($this->ability(true, null, $loader)->execute(['items' => [[
|
||||
'module' => 'button', 'name' => 'Aus Seite', 'from_node' => ['post_id' => 7, 'id' => 's0.r0.c0.m0'],
|
||||
]]]), 'confirm_required');
|
||||
|
||||
self::assertSame('added', $dry['diff'][0]['action']);
|
||||
}
|
||||
|
||||
public function testNamesAreCleanedAndLimited(): void
|
||||
{
|
||||
$ability = $this->ability();
|
||||
$dry = self::assertToolError($ability->execute(self::newButton(['name' => '<b>Fett</b> Name'])), 'confirm_required');
|
||||
|
||||
$this->snapshots->expects('save')->andReturn('presets-1');
|
||||
$this->snapshots->expects('attachLog');
|
||||
$res = $ability->execute(self::newButton(['name' => '<b>Fett</b> Name']) + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
self::assertTrue($res['ok']);
|
||||
$names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name');
|
||||
self::assertContains('Fett Name', $names);
|
||||
self::assertNotContains('<b>Fett</b> Name', $names);
|
||||
|
||||
$long = self::assertToolError($this->ability()->execute(self::newButton(['name' => str_repeat('n', 101)])), 'validation_failed');
|
||||
self::assertStringContainsString('1–100 Zeichen', $long['fields']['items[0].name']);
|
||||
$empty = self::assertToolError($this->ability()->execute(['items' => [
|
||||
['module' => 'button', 'preset' => 'CTA primär', 'rename' => '<i></i>'],
|
||||
]]), 'validation_failed');
|
||||
self::assertStringContainsString('1–100 Zeichen', $empty['fields']['items[0].rename']);
|
||||
}
|
||||
|
||||
public function testNothingIsWrittenWhenOneItemHasErrors(): void
|
||||
{
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$writer->allows('presets')->andReturnUsing(fn (): array => $this->stored);
|
||||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||||
$writer->shouldNotReceive('savePresets');
|
||||
$this->snapshots->shouldNotReceive('save');
|
||||
$ability = $this->ability(true, null, null, $writer);
|
||||
|
||||
$result = $ability->execute(['items' => [
|
||||
['module' => 'button', 'name' => 'Gut'],
|
||||
['module' => 'button', 'name' => 'Schlecht', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'Hallo']]]],
|
||||
], 'confirm' => true, 'design_rev' => 'egal']);
|
||||
|
||||
self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']);
|
||||
self::assertNotEmpty(array_filter(
|
||||
array_keys(Errors::fieldsOf($result)),
|
||||
static fn (string $key): bool => str_starts_with($key, 'items[1]')
|
||||
));
|
||||
}
|
||||
|
||||
public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
|
||||
self::assertToolError($this->ability()->permission(self::newButton()), 'forbidden');
|
||||
}
|
||||
|
||||
public function testSchemaAndMeta(): void
|
||||
{
|
||||
$ability = $this->ability();
|
||||
|
||||
self::assertSame('lit/divi-presets-set', $ability->name());
|
||||
self::assertFalse($ability->isDirect());
|
||||
self::assertSame(DiviPresetsSetAbility::MAX_ITEMS, $ability->inputSchema()['properties']['items']['maxItems']);
|
||||
self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,17 @@ final class PresetDiffTest extends TestCase
|
||||
'action' => 'default_cleared'],
|
||||
], PresetDiff::between($before, $after));
|
||||
}
|
||||
|
||||
public function testSummaryCountsTheActions(): void
|
||||
{
|
||||
self::assertSame(
|
||||
'Presets: 1 angelegt, 1 geändert, 1 gelöscht, Standard 1× gesetzt, 1× entfernt.',
|
||||
PresetDiff::summary([
|
||||
['action' => 'added'], ['action' => 'changed'], ['action' => 'removed'],
|
||||
['action' => 'default_set'], ['action' => 'default_cleared'],
|
||||
])
|
||||
);
|
||||
self::assertSame('Presets: 2 angelegt.', PresetDiff::summary([['action' => 'added'], ['action' => 'added']]));
|
||||
self::assertSame('Presets: keine Änderung.', PresetDiff::summary([]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final class DiviIntegrationTest extends TestCase
|
||||
'lit/divi-node-copy',
|
||||
'lit/divi-dynamic-sources',
|
||||
'lit/divi-preset-get',
|
||||
'lit/divi-presets-set',
|
||||
];
|
||||
|
||||
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
|
||||
|
||||
Reference in New Issue
Block a user