From 2e8f723b01d1574a0e6b6d0e2cfd2b70767d7d3e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 16 Oct 2026 17:50:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/divi-presets-delete=20=E2=80=93=20u?= =?UTF-8?q?nbenutzte=20Presets=20l=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Abilities/Errors.php | 5 +- .../Divi/Design/DiviPresetsDeleteAbility.php | 170 ++++++++++++++++++ src/Integrations/Divi/DiviIntegration.php | 2 + tests/Unit/Abilities/ErrorsTest.php | 9 + .../Design/DiviPresetsDeleteAbilityTest.php | 115 ++++++++++++ .../Integrations/Divi/DiviIntegrationTest.php | 1 + 6 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php create mode 100644 tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index a20df2e..eb06a20 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -80,13 +80,14 @@ final class Errors /** * @param list> $usages + * @param string|null $hint Replaces the default hint (force: true, for lit/media-delete). */ - public static function inUse(string $message, array $usages): WP_Error + public static function inUse(string $message, array $usages, ?string $hint = null): WP_Error { return self::make( 'in_use', $message, - 'Verwendungen prüfen; nur mit force: true trotzdem ausführen.', + $hint ?? 'Verwendungen prüfen; nur mit force: true trotzdem ausführen.', 409, ['usages' => $usages] ); diff --git a/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php b/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php new file mode 100644 index 0000000..f95044a --- /dev/null +++ b/src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php @@ -0,0 +1,170 @@ + 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'preset' => ['type' => 'string'], + ], + 'required' => ['preset'], + ], + ], + ] + DesignChange::inputProperties(), + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(true); + } + + 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 gelöscht.', + ['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 $items */ + private function plan(PresetBook $before, array $items): DesignPlan|WP_Error + { + $book = $before; + $errors = []; + $targets = []; + foreach ($items as $index => $item) { + $field = sprintf('items[%d]', $index); + try { + $scope = PresetScope::fromInput(is_array($item) ? $item : [], $field); + $found = $before->find($scope, is_string($item['preset'] ?? null) ? $item['preset'] : ''); + } catch (InvalidArgumentException $e) { + $errors[$field] = $e->getMessage(); + continue; + } + if ($found === null) { + $errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), (string) $item['preset']); + continue; + } + $id = (string) $found['id']; + $targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id]; + $book = $book->without($scope, $id); + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Angaben; nichts wurde gelöscht.', $errors); + } + + $deleted = array_column($targets, 'id'); + $nested = []; + foreach ($targets as $target) { + foreach ($before->nestedUsers($target['id']) as $user) { + if (! in_array($user['id'], $deleted, true)) { + $nested[] = ['type' => 'preset', 'preset_id' => $user['id'], 'name' => $user['name'], + 'scope' => $user['scope']->name]; + } + } + } + $report = $this->usages->reportFor(new PresetUsageQuery($targets), false); + if ($nested !== [] || $report->exists()) { + return Errors::inUse( + 'Die Presets werden noch verwendet; nichts wurde gelöscht.', + array_merge($nested, $report->listing()['affected']), + self::DETACH_HINT + ); + } + + $diff = PresetDiff::between($before, $book); + + return new DesignPlan($book->data(), $diff, PresetDiff::summary($diff), null, [], true); + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index e0fb791..018d007 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -12,6 +12,7 @@ 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\DiviPresetsDeleteAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility; use LIT\MCP\Integrations\Divi\Design\PresetComposer; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; @@ -166,6 +167,7 @@ final class DiviIntegration implements Integration $loader, $this->presetComposer(...) ), + new DiviPresetsDeleteAbility($this->designWriter, $this->designChange(), $this->globalUsages()), ]; } diff --git a/tests/Unit/Abilities/ErrorsTest.php b/tests/Unit/Abilities/ErrorsTest.php index b41b5a1..3a2245d 100644 --- a/tests/Unit/Abilities/ErrorsTest.php +++ b/tests/Unit/Abilities/ErrorsTest.php @@ -134,4 +134,13 @@ final class ErrorsTest extends TestCase self::assertSame([], Errors::fieldsOf(Errors::invalidInput('x'))); self::assertSame([], Errors::fieldsOf(new \WP_Error('db', 'x', 'kein Array'))); } + + public function testInUseTakesItsOwnHint(): void + { + $error = Errors::inUse('Wird verwendet.', [['post_id' => 3]], 'Erst die Verweise entfernen.'); + + $payload = self::assertToolError($error, 'in_use'); + self::assertSame('Erst die Verweise entfernen.', $payload['hint']); + self::assertStringContainsString('force: true', Errors::inUse('x', [])->get_error_data()['hint']); + } } diff --git a/tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php new file mode 100644 index 0000000..b3eb004 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php @@ -0,0 +1,115 @@ +justReturn(true); + if ($writer === null) { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + } + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report); + + return new DiviPresetsDeleteAbility( + $writer, + new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)), + $usages + ); + } + + private static function unused(): UsageReport + { + return new UsageReport(0, [], 0, 0, [], 0, false); + } + + public function testUnusedPresetIsDeletedAfterConfirm(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->expects('savePresets') + ->with(Mockery::on(static function (array $rest): bool { + $ids = array_column($rest['module'][0]['items'], 'id'); + + return $ids === ['aaaaaaaaaa', 'cccccccccc']; + }), true) + ->andReturnUsing(static function (): array { + $stored = PresetBookTest::stored(); + unset($stored['module']['divi/button']['items']['bbbbbbbbbb']); + + return $stored; + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('presets-9'); + $snapshots->expects('attachLog'); + $ability = $this->ability(self::unused(), $writer, $snapshots); + $input = ['items' => [['module' => 'button', 'preset' => 'bbbbbbbbbb']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + self::assertSame('removed', $dry['diff'][0]['action']); + + $result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertSame('presets-9', $result['snapshot_id']); + } + + public function testPresetInUseIsRefused(): void + { + $used = new UsageReport(2, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], + 1, 1, [], 0, false); + + $error = self::assertToolError( + $this->ability($used)->execute(['items' => [['module' => 'button', 'preset' => 'CTA primär']]]), + 'in_use' + ); + + self::assertSame(3, $error['usages'][0]['post_id']); + self::assertStringContainsString('lit/divi-design-detach', $error['hint']); + } + + public function testNestedUseInAnotherPresetCounts(): void + { + $error = self::assertToolError( + $this->ability(self::unused())->execute(['items' => [['group' => 'font-body', 'preset' => 'Lesetext']]]), + 'in_use' + ); + + self::assertSame('preset', $error['usages'][0]['type']); + self::assertSame('dddddddddd', $error['usages'][0]['preset_id']); + } + + public function testDeletingTheDefaultClearsIt(): void + { + $dry = self::assertToolError( + $this->ability(self::unused())->execute(['items' => [['module' => 'text', 'preset' => 'Fließtext']]]), + 'confirm_required' + ); + + self::assertSame(['removed', 'default_cleared'], array_column($dry['diff'], 'action')); + } + + public function testUnknownPreset(): void + { + self::assertToolError( + $this->ability(self::unused())->execute(['items' => [['module' => 'button', 'preset' => 'Fehlt']]]), + 'validation_failed' + ); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 8b0970a..4fe1f0a 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -33,6 +33,7 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-dynamic-sources', 'lit/divi-preset-get', 'lit/divi-presets-set', + 'lit/divi-presets-delete', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void