diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php index 7c35263..03935e6 100644 --- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -7,7 +7,10 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\Compose\ColumnWidths; +use LIT\MCP\Integrations\Divi\Compose\Composer; +use LIT\MCP\Integrations\Divi\Compose\LoopScope; use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; @@ -21,6 +24,9 @@ defined('ABSPATH') || exit; * come back together; only then are the attributes Composer::update() computed set. A node may * appear once per call (ruling S14); set and attrs of one change must not reach the same path * (ruling S38, checked by Composer::update()). + * + * Loop content (ruling X3): each change is checked in the loop scope of its node – the stored ancestors plus the + * loop attrs this call gives the node or an ancestor – with one Composer per scope. */ final class DiviUpdateAbility extends DiviWriteAbility { @@ -123,14 +129,13 @@ final class DiviUpdateAbility extends DiviWriteAbility ]); } - $composer = $composition->composer(); $errors = []; /** @var list $invalidPaths */ $invalidPaths = []; /** @var array $seen id => position of its first change */ $seen = []; - /** @var list, 2: string}> $updates */ - $updates = []; + /** @var list, 2: string}> $pending */ + $pending = []; foreach ($changes as $position => $change) { $field = sprintf('changes[%d]', $position); if (! is_array($change)) { @@ -159,10 +164,15 @@ final class DiviUpdateAbility extends DiviWriteAbility continue; } - $result = $composer->update($block, $change, $field); + $pending[] = [$block, $change, $field]; + } + + /** @var list, 2: string}> $updates */ + $updates = []; + foreach (self::composeInLoops($page->index, $composition, $pending) as $position => $result) { $errors += $result['errors']; array_push($invalidPaths, ...$result['invalid_paths']); - $updates[] = [$block, $result['attrs'], $field]; + $updates[] = [$pending[$position][0], $result['attrs'], $pending[$position][2]]; } if ($errors === []) { @@ -189,4 +199,46 @@ final class DiviUpdateAbility extends DiviWriteAbility return $change; } + + /** + * Composer::update() of every pending change in its loop scope (ruling X3). First with the stored ancestors; + * then changes whose scope the new attrs of this call move (a loop switched on, off or to another kind on the + * node or an ancestor) are composed again in that scope – loop attrs are no client content, so the second + * pass sees the same loops. + * + * @param list, 2: string}> $pending + * @return list, errors: array, invalid_paths: list}> + */ + private static function composeInLoops(NodeIndex $index, DiviComposition $composition, array $pending): array + { + /** @var array $composers query type ('' = no loop) => Composer of that scope */ + $composers = []; + $scopes = []; + $results = []; + foreach ($pending as $position => [$block, $change, $field]) { + $scopes[$position] = self::loopAt($index, $block); + $results[$position] = self::composerFor($composition, $scopes[$position], $composers) + ->update($block, $change, $field); + } + + $newAttrs = []; + foreach ($pending as $position => [$block]) { + $newAttrs[(string) $index->idOf($block)] = $results[$position]['attrs']; + } + foreach ($pending as $position => [$block, $change, $field]) { + $scope = self::loopAt($index, $block, $newAttrs); + if ($scope->queryType() !== $scopes[$position]->queryType()) { + $results[$position] = self::composerFor($composition, $scope, $composers) + ->update($block, $change, $field); + } + } + + return $results; + } + + /** @param array $composers One Composer per loop scope (S31 as reworded by X3). */ + private static function composerFor(DiviComposition $composition, LoopScope $scope, array &$composers): Composer + { + return $composers[$scope->queryType() ?? ''] ??= $composition->inLoop($scope)->composer(); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php b/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php index f28f89b..beb92a2 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php @@ -172,4 +172,53 @@ final class DiviLoopWriteTest extends DiviWriteAbilityTestCase ]]); self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent)); } + + /** Ruling X3: one Composer per loop scope – changes in a post loop and a term loop in one call. */ + public function testUpdateChecksEveryChangeInItsOwnLoop(): void + { + $result = $this->changeAll([ + ['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:post_date}}']], + ['id' => 's0.r0.c1.m0', 'set' => ['content' => '{{loop:term_name}}']], + ['id' => 's0.r0.c2.m0', 'set' => ['content' => '

Neu

']], + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + self::assertStringContainsString('loop_term_name', $this->saved[0]); + + $refused = $this->changeAll([ + ['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:term_name}}']], + ['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:post_date}}']], + ]); + $problems = self::errorFields($refused); + self::assertStringContainsString('Typ: Beiträge', $problems['changes[0].set.content'] ?? ''); + self::assertStringContainsString('braucht eine Divi-Schleife', $problems['changes[1].set.content'] ?? ''); + } + + /** + * Ruling X3: the scope of a change takes the loop attrs this call gives the node or an ancestor – a loop + * switched on admits its content, a loop switched to another kind refuses the old kind's content. + */ + public function testUpdateUsesTheLoopsThisCallSets(): void + { + $switchedOn = $this->changeAll([ + ['id' => 's0.r0.c2', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]], + ['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:term_name}}']], + ]); + self::assertTrue($switchedOn['ok'], (string) json_encode($switchedOn)); + + $own = $this->changeAll([[ + 'id' => 's0.r0.c0.m1', + 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]], + 'set' => ['content' => '{{loop:term_name}}'], + ]]); + self::assertTrue($own['ok'], (string) json_encode($own)); + + $switchedKind = $this->changeAll([ + ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]], + ['id' => 's0.r0.c0.m0', 'set' => ['content' => '{{loop:post_date}}']], + ]); + $problems = self::errorFields($switchedKind); + self::assertStringContainsString('Typ: Terme', $problems['changes[1].set.content'] ?? ''); + self::assertCount(2, $this->saved); + } }