Skip to content
Snippets Groups Projects
Commit a1410b6f authored by Ted Bowman's avatar Ted Bowman Committed by Wim Leers
Browse files

Issue #3455728 by tedbow, Wim Leers: FieldType: Support storing component...

Issue #3455728 by tedbow, Wim Leers: FieldType: Support storing component *trees* instead of *lists*
parent 660fbc41
No related branches found
No related tags found
1 merge request!67Resolve #3455728 "ComponentTreeStructure to use a Real tree instead of list"
Pipeline #222647 passed
Showing with 201 additions and 74 deletions
......@@ -353,10 +353,6 @@ pages:
- if: $CI_COMMIT_BRANCH == "staging" # Run on master (with default PAGES_PREFIX)
variables:
PAGES_PREFIX: '-stg-' # Prefix with -stg- for the staging branch
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # Conditionally change the prefix for Merge Requests
when: manual # Run pages manually on Merge Requests
variables:
PAGES_PREFIX: 'mr-$CI_MERGE_REQUEST_IID' # Prefix with the mr-<iid>, like `mr-123`
🗺️ C4 model diagrams:
stage: validate
......
......@@ -17,7 +17,8 @@ description: ''
required: true
translatable: false
default_value:
- tree: '[{"uuid":"dynamic-image-udf7d","type":"experience_builder:image"},{"uuid":"static-static-card1ab","type":"sdc_test:my-cta"},{"uuid":"dynamic-static-card2df","type":"sdc_test:my-cta"},{"uuid":"dynamic-dynamic-card3rr","type":"sdc_test:my-cta"},{"uuid":"dynamic-image-static-imageStyle-something7d","type":"experience_builder:image"}]'
# Use mandatory \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure::ROOT_UUID as the key.
- tree: '{"a548b48d-58a8-4077-aa04-da9405a6f418": [{"uuid":"dynamic-image-udf7d","component":"experience_builder:image"},{"uuid":"static-static-card1ab","component":"sdc_test:my-cta"},{"uuid":"dynamic-static-card2df","component":"sdc_test:my-cta"},{"uuid":"dynamic-dynamic-card3rr","component":"sdc_test:my-cta"},{"uuid":"dynamic-image-static-imageStyle-something7d","component":"experience_builder:image"}]}'
props: '{"dynamic-static-card2df":{"text":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝title␞␟value"},"href":{"sourceType":"static:field_item:uri","value":"https:\/\/drupal.org","expression":"ℹ︎uri␟value"}},"static-static-card1ab":{"text":{"sourceType":"static:field_item:string","value":"hello, world!","expression":"ℹ︎string␟value"},"href":{"sourceType":"static:field_item:uri","value":"https:\/\/drupal.org","expression":"ℹ︎uri␟value"}},"dynamic-dynamic-card3rr":{"text":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝title␞␟value"},"href":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝field_hero␞␟entity␜␜entity:file␝uri␞␟value"}},"dynamic-image-udf7d":{"image":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝field_hero␞␟{src↝entity␜␜entity:file␝uri␞␟value,alt↠alt,width↠width,height↠height}"}},"dynamic-image-static-imageStyle-something7d":{"image":{"sourceType":"adapter:image_apply_style","adapterInputs":{"image":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝field_hero␞␟{src↝entity␜␜entity:file␝uri␞0␟value,alt↠alt,width↠width,height↠height}"},"imageStyle":{"sourceType":"static:field_item:string","value":"thumbnail","expression":"ℹ︎string␟value"}}}}}'
default_value_callback: ''
settings:
......
......@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Drupal\experience_builder\Plugin\DataType;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Serialization\Json;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\Attribute\DataType;
......@@ -19,6 +20,8 @@ use Drupal\Core\TypedData\TypedData;
)]
class ComponentTreeStructure extends TypedData {
const ROOT_UUID = 'a548b48d-58a8-4077-aa04-da9405a6f418';
/**
* The data value.
*
......@@ -29,7 +32,8 @@ class ComponentTreeStructure extends TypedData {
/**
* The parsed data value.
*
* @var array<int, array{'uuid': string, 'type': string}>
* @var array<string,array<int, array{'uuid': string, 'component': string}>|array<string, array<int, array{'uuid': string, 'component': string}>>
* >
*/
protected array $tree = [];
......@@ -46,8 +50,8 @@ class ComponentTreeStructure extends TypedData {
* {@inheritdoc}
*/
public function applyDefaultValue($notify = TRUE) {
// Default to the empty JSON array.
$this->setValue('[]', $notify);
// Default to a JSON object with only the root key present.
$this->setValue('{"' . ComponentTreeStructure::ROOT_UUID . '": []}', $notify);
return $this;
}
......@@ -58,6 +62,18 @@ class ComponentTreeStructure extends TypedData {
// @todo Delete next line; update this code to ONLY do the JSON-to-PHP-object parsing after https://www.drupal.org/project/drupal/issues/2232427 lands — that will allow specifying the "json" serialization strategy rather than only PHP's serialize().
$this->value = $value;
$this->tree = Json::decode($value);
// @todo These are temporary checks until we have a constraint. Just to make
// sure tests fail if we don't have valid test values. Add actual
// constraint in https://drupal.org/i/3460856.
if (!isset($this->tree[ComponentTreeStructure::ROOT_UUID]) || !array_is_list($this->tree[ComponentTreeStructure::ROOT_UUID])) {
throw new \UnexpectedValueException('Temp exception replace with constraint. Root UUID is missing or incorrect:' . $value);
}
foreach (array_keys($this->tree) as $top_level_uuid) {
assert(is_string($top_level_uuid));
if ($top_level_uuid !== ComponentTreeStructure::ROOT_UUID && substr_count($value, $top_level_uuid) !== 2) {
throw new \UnexpectedValueException("Temp exception replace with constraint. Top level UUID, $top_level_uuid does not appear in tree.");
}
}
// Notify the parent of any changes.
if ($notify && isset($this->parent)) {
......@@ -77,7 +93,39 @@ class ComponentTreeStructure extends TypedData {
* Component instance UUIDs.
*/
public function getComponentInstanceUuids(): array {
return array_column($this->tree, 'uuid');
return array_column($this->getComponents(), 'uuid');
}
/**
* @return array<array{uuid: string, component: string}>
*/
private function getComponents(): array {
$components = [];
// For the remainder of the structure, assume two levels as per the requirement.
foreach ($this->tree as $uuid => $sub_tree_value) {
if ($uuid === self::ROOT_UUID) {
$components = array_merge($components, $sub_tree_value);
continue;
}
foreach ($sub_tree_value as $section_name => $items) {
if (!is_array($items)) {
throw new \UnexpectedValueException(sprintf('Expected an array of items expect in %s, but got %s.', $section_name, gettype($items)));
}
// Efficiently extract UUID values from each inner array.
$components = array_merge($components, $items);
}
}
assert(Inspector::assertAllArrays($components));
assert(Inspector::assertAll(fn (array $a) => array_keys($a) == ['uuid', 'component'], $components));
// TRICKY: PHPStan gets confused by the array shape of $this->tree, and does
// not understand the above assertions. Those assertions guarantee that the
// documented return array shape is actually met.
// @phpstan-ignore-next-line
return $components;
}
/**
......@@ -90,17 +138,18 @@ class ComponentTreeStructure extends TypedData {
if (!in_array($component_instance_uuid, $this->getComponentInstanceUuids(), TRUE)) {
throw new \OutOfRangeException(sprintf('No component stored for %s. Caused by either incorrect logic or `props` being out of sync with `tree`.', $component_instance_uuid));
}
$components = $this->getComponents();
$index = array_search($component_instance_uuid, array_column($this->tree, 'uuid'));
$index = array_search($component_instance_uuid, array_column($components, 'uuid'));
return $this->tree[$index]['type'];
return $components[$index]['component'];
}
/**
* @return array<string>
*/
public function getComponentIdList(): array {
return array_unique(array_column($this->tree, 'type'));
return array_values(array_unique(array_column($this->getComponents(), 'component')));
}
}
......@@ -14,6 +14,7 @@ use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\experience_builder\Plugin\Adapter\AdapterInterface;
use Drupal\experience_builder\Plugin\DataType\ComponentPropsValues;
use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
......@@ -222,8 +223,9 @@ class TwoTerribleTextAreasWidget extends WidgetBase {
// TRICKY: This manual JSON handling is necessary due to https://www.drupal.org/project/drupal/issues/2232427 not having landed yet.
$props = json_decode($values[0]['props'], TRUE);
foreach (json_decode($values[0]['tree'], TRUE) as $component_instance) {
$component_instance_uuid = $component_instance['uuid'];
$tree_structure = ComponentTreeStructure::createInstance(DataDefinition::create('component_tree_structure'));
$tree_structure->setValue($values[0]['tree']);
foreach ($tree_structure->getComponentInstanceUuids() as $component_instance_uuid) {
// Not every component instance has static prop sources to edit.
if (!array_key_exists($component_instance_uuid, $edited_sdc_props)) {
......
......@@ -17,7 +17,8 @@ description: ''
required: true
translatable: false
default_value:
- tree: '[{"uuid":"dynamic-static-card2df","type":"sdc_test:my-cta"}]'
# Use mandatory \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure::ROOT_UUID as the key.
- tree: '{"a548b48d-58a8-4077-aa04-da9405a6f418": [{"uuid":"dynamic-static-card2df","component":"sdc_test:my-cta"}]}'
# cspell:ignore centity dtitle elink furi fvalue
props: '{"dynamic-static-card2df":{"text":{"sourceType":"dynamic","expression":"\u2139\ufe0e\u241centity:node:article\u241dtitle\u241e\u241fvalue"},"href":{"sourceType":"static:field_item:link","value":{"uri":"https:\/\/drupal.org","title":null,"options":[]},"expression":"\u2139\ufe0elink\u241furi"}}}'
default_value_callback: ''
......
......@@ -114,25 +114,27 @@ class EndToEndDemoIntegrationTest extends BrowserTestBase {
$this->assertInstanceOf(ComponentTreeStructure::class, $tree);
// First, assert the stored JSON.
$this->assertEquals([
[
'uuid' => 'dynamic-image-udf7d',
'type' => 'experience_builder:image',
],
[
'uuid' => 'static-static-card1ab',
'type' => 'sdc_test:my-cta',
],
[
'uuid' => 'dynamic-static-card2df',
'type' => 'sdc_test:my-cta',
],
[
'uuid' => 'dynamic-dynamic-card3rr',
'type' => 'sdc_test:my-cta',
],
[
'uuid' => 'dynamic-image-static-imageStyle-something7d',
'type' => 'experience_builder:image',
ComponentTreeStructure::ROOT_UUID => [
[
'uuid' => 'dynamic-image-udf7d',
'component' => 'experience_builder:image',
],
[
'uuid' => 'static-static-card1ab',
'component' => 'sdc_test:my-cta',
],
[
'uuid' => 'dynamic-static-card2df',
'component' => 'sdc_test:my-cta',
],
[
'uuid' => 'dynamic-dynamic-card3rr',
'component' => 'sdc_test:my-cta',
],
[
'uuid' => 'dynamic-image-static-imageStyle-something7d',
'component' => 'experience_builder:image',
],
],
], json_decode($tree->getValue(), TRUE));
// Second, assert the interpreted results.
......
......@@ -6,6 +6,7 @@ namespace Drupal\Tests\experience_builder\Kernel;
use Drupal\Core\Database\Database;
use Drupal\Core\Extension\ModuleUninstallValidatorException;
use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\experience_builder\Traits\ContribStrictConfigSchemaTestTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
......@@ -52,7 +53,7 @@ final class FieldTypeUninstallValidatorTest extends KernelTestBase {
'title' => 'Test node',
'type' => 'article',
'field_xb_test' => [
'tree' => '[{"uuid":"dynamic-static-card2df","type":"sdc_test:my-cta"}]',
'tree' => '{"' . ComponentTreeStructure::ROOT_UUID . '": [{"uuid":"dynamic-static-card2df","component":"sdc_test:my-cta"}]}',
'props' => '{"dynamic-static-card2df":{"text":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝title␞␟value"},"href":{"sourceType":"static:field_item:link","value":{"uri":"https:\/\/drupal.org","title":null,"options":[]},"expression":"ℹ︎link␟uri"}}}',
],
]);
......
<?php
declare(strict_types=1);
namespace Drupal\Tests\experience_builder\Kernel\Plugin\DataType;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
use Drupal\Tests\UnitTestCase;
/**
* Tests testGetComponentIdList() in ComponentTreeStructure.
*
* @group experience_builder
*/
class ComponentTreeStructureTest extends UnitTestCase {
// cspell:disable-next-line
const VALUE = '[{"uuid":"dynamic-image-udf7d","type":"experience_builder:image"},{"uuid":"static-static-card1ab","type":"sdc_test:my-cta"},{"uuid":"dynamic-static-card2df","type":"sdc_test:my-cta"},{"uuid":"dynamic-dynamic-card3rr","type":"sdc_test:my-cta"},{"uuid":"dynamic-image-static-imageStyle-something7d","type":"experience_builder:image"}]';
const COMPONENT_IDS = ['experience_builder:image', 'sdc_test:my-cta'];
protected ComponentTreeStructure $tree;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->tree = ComponentTreeStructure::createInstance(DataDefinition::create('component_tree_structure'));
}
public function testGetComponentIdList(): void {
$this->assertSame([], $this->tree->getComponentIdList());
$this->tree->setValue(self::VALUE);
$this->assertSame(self::COMPONENT_IDS, $this->tree->getComponentIdList());
}
}
......@@ -6,6 +6,7 @@ namespace Drupal\Tests\experience_builder\Kernel\Plugin\Field\FieldType;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\experience_builder\Entity\Component;
use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\experience_builder\Traits\ContribStrictConfigSchemaTestTrait;
......@@ -20,7 +21,7 @@ class ComponentTreeItemTest extends KernelTestBase {
use ContribStrictConfigSchemaTestTrait;
const DEFAULT_VALUE = [
'tree' => '[{"uuid":"dynamic-image-udf7d","type":"experience_builder:image"},{"uuid":"static-static-card1ab","type":"sdc_test:my-cta"},{"uuid":"dynamic-static-card2df","type":"sdc_test:my-cta"},{"uuid":"dynamic-dynamic-card3rr","type":"sdc_test:my-cta"},{"uuid":"dynamic-image-static-imageStyle-something7d","type":"experience_builder:image"}]',
'tree' => '{"' . ComponentTreeStructure::ROOT_UUID . '": [{"uuid":"dynamic-image-udf7d","component":"experience_builder:image"},{"uuid":"static-static-card1ab","component":"sdc_test:my-cta"},{"uuid":"dynamic-static-card2df","component":"sdc_test:my-cta"},{"uuid":"dynamic-dynamic-card3rr","component":"sdc_test:my-cta"},{"uuid":"dynamic-image-static-imageStyle-something7d","component":"experience_builder:image"}]}',
'props' => '{"dynamic-static-card2df":{"text":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝title␞␟value"},"href":{"sourceType":"static:field_item:uri","value":"https:\/\/drupal.org","expression":"ℹ︎uri␟value"}},"static-static-card1ab":{"text":{"sourceType":"static:field_item:string","value":"hello, world!","expression":"ℹ︎string␟value"},"href":{"sourceType":"static:field_item:uri","value":"https:\/\/drupal.org","expression":"ℹ︎uri␟value"}},"dynamic-dynamic-card3rr":{"text":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝title␞␟value"},"href":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝field_hero␞␟entity␜␜entity:file␝uri␞␟value"}},"dynamic-image-udf7d":{"image":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝field_hero␞␟{src↝entity␜␜entity:file␝uri␞␟value,alt↠alt,width↠width,height↠height}"}},"dynamic-image-static-imageStyle-something7d":{"image":{"sourceType":"adapter:image_apply_style","adapterInputs":{"image":{"sourceType":"dynamic","expression":"ℹ︎␜entity:node:article␝field_hero␞␟{src↝entity␜␜entity:file␝uri␞0␟value,alt↠alt,width↠width,height↠height}"},"imageStyle":{"sourceType":"static:field_item:string","value":"thumbnail","expression":"ℹ︎string␟value"}}}}}',
];
const EXPECTED_DEPENDENCIES = [
......
<?php
declare(strict_types=1);
namespace Drupal\Tests\experience_builder\Unit\DataType;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure
*/
class ComponentTreeStructureTest extends UnitTestCase {
/**
* @covers ::getValue
*/
public function testGetValue(): void {
$data = DataDefinition::create('component_tree_structure');
$component_tree_structure = new ComponentTreeStructure($data, 'component_tree_structure', NULL);
$component_tree_structure->setValue('{"' . ComponentTreeStructure::ROOT_UUID . '": []}');
$this->assertSame('{"' . ComponentTreeStructure::ROOT_UUID . '": []}', $component_tree_structure->getValue());
}
/**
* @covers ::getComponentInstanceUuids
*/
public function testGetComponentInstanceUuids(): void {
$this->assertSame(
['uuid-root-1', 'uuid-root-2', 'uuid-root-3', 'uuid4-author1', 'uuid2-submitted', 'uuid5-author2', 'uuid4-author3'],
$this->getTestComponentTreeStructure()->getComponentInstanceUuids());
}
/**
* @covers ::getComponentId
*/
public function testGetComponentId(): void {
$component_tree_structure = $this->getTestComponentTreeStructure();
$this->assertSame('provider:two-col', $component_tree_structure->getComponentId('uuid-root-1'));
$this->assertSame('provider:marquee', $component_tree_structure->getComponentId('uuid-root-2'));
$this->assertSame('provider:person-card', $component_tree_structure->getComponentId('uuid4-author1'));
$this->assertSame('provider:elegant-date', $component_tree_structure->getComponentId('uuid2-submitted'));
$this->assertSame('provider:person-card', $component_tree_structure->getComponentId('uuid5-author2'));
}
/**
* @covers ::getComponentIdList
*/
public function testGetComponentIdList(): void {
$this->assertSame(
[],
ComponentTreeStructure::createInstance(DataDefinition::create('component_tree_structure'))->getComponentIdList()
);
$this->assertSame(
['provider:two-col', 'provider:marquee', 'provider:person-card', 'provider:elegant-date'],
$this->getTestComponentTreeStructure()->getComponentIdList()
);
}
/**
* @covers ::getComponentId
*/
public function testGetComponentIdMissing(): void {
$this->expectException(\OutOfRangeException::class);
$this->expectExceptionMessage('No component stored for uuid-missing. Caused by either incorrect logic or `props` being out of sync with `tree`.');
$this->getTestComponentTreeStructure()->getComponentId('uuid-missing');
}
/**
* @covers ::applyDefaultValue
*/
public function testApplyDefaultValue(): void {
$component_tree_structure = new ComponentTreeStructure(DataDefinition::create('component_tree_structure'), 'component_tree_structure', NULL);
$component_tree_structure->applyDefaultValue();
$this->assertSame('{"' . ComponentTreeStructure::ROOT_UUID . '": []}', $component_tree_structure->getValue());
}
/**
* @return \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure
*/
private function getTestComponentTreeStructure(): ComponentTreeStructure {
$test_json = str_replace('ROOT_UUID', ComponentTreeStructure::ROOT_UUID, '
{
"ROOT_UUID": [
{"uuid": "uuid-root-1", "component": "provider:two-col"},
{"uuid": "uuid-root-2", "component": "provider:marquee"},
{"uuid": "uuid-root-3", "component": "provider:marquee"}
],
"uuid-root-1": {
"firstColumn": [
{"uuid": "uuid4-author1", "component": "provider:person-card"},
{"uuid": "uuid2-submitted", "component": "provider:elegant-date"}
],
"secondColumn": [
{"uuid": "uuid5-author2", "component": "provider:person-card"}
]
},
"uuid-root-2": {
"content": [
{"uuid": "uuid4-author3", "component": "provider:person-card"}
]
}
}');
$definition = DataDefinition::create('component_tree_structure');
$component_tree_structure = new ComponentTreeStructure($definition,
'component_tree_structure');
$component_tree_structure->setValue($test_json);
return $component_tree_structure;
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment