Skip to content
Snippets Groups Projects
Commit 0a04bd43 authored by Ben Mullins's avatar Ben Mullins Committed by Wim Leers
Browse files

Issue #3455975 by tedbow, bnjmnm, Wim Leers: HTTP API: update...

Issue #3455975 by tedbow, bnjmnm, Wim Leers: HTTP API: update /xb-component/{component_id} to list possible prop sources for current entity context
parent 6efdbece
No related branches found
Tags 1.0.0-alpha17
1 merge request!167#3455975: HTTP API: update /xb-component/{component_id} to list possible prop sources for current entity context
Pipeline #268376 failed
......@@ -7,8 +7,16 @@ openapi: 3.1.0
info:
version: 0.x
title: Experience Builder
description: API Spec for Experience Builder
description: API Spec for Experience Builder
paths:
/xb-field-form/{entityTypeId}/{id}:
get:
parameters:
- $ref: '#/components/parameters/entityTypeId'
- $ref: '#/components/parameters/id'
responses:
200:
description: 'The form'
/api/layout/{entityTypeId}/{id}:
get:
parameters:
......@@ -246,6 +254,8 @@ components:
type: [string, integer, number, boolean, object]
default_markup:
type: string
dynamic_prop_source_candidates:
type: object
additionalProperties: false
Layout:
title: layout
......
......@@ -14,14 +14,20 @@ use Drupal\Core\Render\BareHtmlPageRendererInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Theme\ComponentPluginManager;
use Drupal\Core\Entity\TypedData\EntityDataDefinition;
use Drupal\Core\TypedData\TypedDataManagerInterface;
use Drupal\experience_builder\Entity\Component;
use Drupal\experience_builder\FieldForComponentSuggester;
use Drupal\experience_builder\Plugin\DataType\ComponentTreeHydrated;
use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
use Drupal\experience_builder\PropExpressions\Component\ComponentPropExpression;
use Drupal\experience_builder\PropExpressions\StructuredData\FieldObjectPropsExpression;
use Drupal\experience_builder\PropExpressions\StructuredData\FieldPropExpression;
use Drupal\experience_builder\PropExpressions\StructuredData\ReferenceFieldPropExpression;
use Drupal\experience_builder\PropShape;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
......@@ -54,8 +60,24 @@ final class SdcController extends ControllerBase {
protected AssetCollectionRendererInterface $cssCollectionRenderer,
protected readonly BareHtmlPageRendererInterface $bareHtmlPageRenderer,
private readonly TypedDataManagerInterface $typedDataManager,
protected readonly FieldForComponentSuggester $fieldForComponentSuggester,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.sdc'),
$container->get('renderer'),
$container->get('asset.resolver'),
$container->get('asset.css.collection_renderer'),
$container->get(BareHtmlPageRendererInterface::class),
$container->get(TypedDataManagerInterface::class),
$container->get(FieldForComponentSuggester::class),
);
}
private function buildLayout(array &$layout, array &$model, ComponentTreeItem $item, array $tree_tier, array $hydrated): void {
$tree = $item->get('tree');
assert($tree instanceof ComponentTreeStructure);
......@@ -105,6 +127,8 @@ final class SdcController extends ControllerBase {
foreach (Component::loadMultiple() as $component) {
$component_plugin = $this->componentPluginManager->find($component->getComponentMachineName());
$keyed_choices = [];
$suggestions = $this->fieldForComponentSuggester->suggest($component_plugin->getPluginId(), EntityDataDefinition::create('node', 'article'));
$dynamic_prop_source_candidates = [];
foreach (PropShape::getComponentProps($component_plugin) as $component_prop_expression => $prop_shape) {
$storable_prop_shape = $prop_shape->getStorage();
// @todo Remove this once every SDC prop shape can be stored.
......@@ -114,7 +138,12 @@ final class SdcController extends ControllerBase {
}
$static_prop_source = $storable_prop_shape->toStaticPropSource();
$component_prop = ComponentPropExpression::fromString($component_prop_expression);
if (isset($suggestions[$component_prop_expression])) {
$dynamic_prop_source_candidates[$component_prop->propName] = array_map(
fn (FieldPropExpression|FieldObjectPropsExpression|ReferenceFieldPropExpression $expr) => (string) $expr,
$suggestions[$component_prop_expression]['instances']
);
}
$keyed_choices[$component_prop->propName] = [
'expression' => (string) $storable_prop_shape->fieldTypeProp,
'sourceType' => $static_prop_source->getSourceType(),
......@@ -155,6 +184,7 @@ final class SdcController extends ControllerBase {
// A pre-rendered version of the component is provided so no requests
// are needed when adding it to the layout.
'default_markup' => $css . $default_markup,
'dynamic_prop_source_candidates' => $dynamic_prop_source_candidates,
];
}
......
......@@ -9,6 +9,8 @@ use Drupal\Core\Routing\RouteMatchInterface;
use League\OpenAPIValidation\PSR7\Exception\Validation\AddressValidationFailed;
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
use League\OpenAPIValidation\PSR7\OperationAddress;
use League\OpenAPIValidation\PSR7\ResponseValidator;
use League\OpenAPIValidation\PSR7\SpecFinder;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
......@@ -127,6 +129,8 @@ final class ApiResponseValidator implements EventSubscriberInterface {
try {
$validator->validate($operation, $psr7_response);
$this->performXbValidation($validator, $operation, $response);
return TRUE;
}
catch (ValidationFailed $e) {
......@@ -144,4 +148,32 @@ final class ApiResponseValidator implements EventSubscriberInterface {
}
}
public static function validateKeys(array $data, string $pattern): void {
foreach (array_keys($data) as $key) {
if (!preg_match("/$pattern/", $key)) {
throw new ValidationFailed(sprintf('Invalid key "%s" found in data array.', $key));
}
}
}
private function performXbValidation(ResponseValidator $validator, OperationAddress $operation, Response $response): void {
$schema = $validator->getSchema();
$spec_finder = new SpecFinder($schema);
$path = $spec_finder->findPathSpec($operation);
if ($operation->method() === 'get' && isset($path->get) && isset($path->get->responses[$response->getStatusCode()])) {
$extensions = $path->get->responses[$response->getStatusCode()]->getExtensions();
if (isset($extensions['x-xb-validation'])) {
assert(isset($extensions['x-xb-validation']['method']), 'Method not found in x-xb-validation extension.');
assert(method_exists(static::class, $extensions['x-xb-validation']['method']));
$content = $response->getContent();
assert(is_string($content));
$args = array_merge([json_decode($content, TRUE)], $extensions['x-xb-validation']['arguments'] ?? []);
$callback = [$this, $extensions['x-xb-validation']['method']];
assert(is_callable($callback));
call_user_func_array($callback, $args);
}
}
}
}
......@@ -110,7 +110,7 @@ final class FieldForComponentSuggester {
$component = $this->componentPluginManager->find($component_plugin_id);
/** @var array<string, mixed> $schema */
$schema = $component->metadata->schema;
$suggestions[$cpe]['required'] = in_array($prop_name, $schema['required'], TRUE);
$suggestions[$cpe]['required'] = in_array($prop_name, $schema['required'] ?? [], TRUE);
// Field types.
// @todo Ensure these expressions do not break: https://www.drupal.org/project/experience_builder/issues/3450957
......
......@@ -31,6 +31,7 @@ describe(
.find('[data-component-id="experience_builder:two_column"] .column-one')
.first()
.trigger('click');
cy.findByLabelText('Column Width').should('exist')
cy.get('button[aria-label="Add section"]').then((button) => {
button.click();
});
......
......@@ -31,6 +31,7 @@ describe('Undo/Redo functionality', { testIsolation: false }, () => {
.find('[data-component-id="experience_builder:two_column"] .column-one')
.first()
.trigger('click');
cy.findByLabelText('Column Width').should('exist');
cy.get('button[aria-label="Add section"]').then((button) => {
button.click();
});
......@@ -106,20 +107,21 @@ describe('Undo/Redo functionality', { testIsolation: false }, () => {
// Click the Undo button, see if the value is "hello, world! one".
cy.get('button[aria-label="Undo"]').click();
cy.findByTestId(/^xb-component-form-.*/)
.findByLabelText('Heading')
.should('have.value', 'hello, world! one');
cy.findByLabelText('Heading').should((input) => {
expect(input).to.have.value('hello, world! one');
});
// Click the Redo button, see if the value is "hello, world! one two".
cy.get('button[aria-label="Redo"]').click();
cy.findByTestId(/^xb-component-form-.*/)
.findByLabelText('Heading')
.should('have.value', 'hello, world! one two');
cy.findByLabelText('Heading').should((input) => {
expect(input).to.have.value('hello, world! one two');
});
// Click the Undo button twice, see if the value is "hello, world!".
cy.get('button[aria-label="Undo"]').click().click();
cy.findByTestId(/^xb-component-form-.*/)
.findByLabelText('Heading')
.should('have.value', 'hello, world!');
cy.findByLabelText('Heading').should((input) => {
expect(input).to.have.value('hello, world!');
});
});
});
......@@ -240,6 +240,9 @@ describe('General Experience Builder', { testIsolation: false }, () => {
// Confirm the current values of the first "My Hero" component so we can
// be certain these values later change.
cy.get(
'iframe[data-xb-preview="lg"][data-test-xb-content-initialized="true"]',
).should('exist');
cy.testInIframe('[data-xb-type="experience_builder:my-hero"]', (heroes) => {
const hero = heroes[0];
Object.entries(heroSelectors).forEach(([prop, selector]) => {
......@@ -299,6 +302,9 @@ describe('General Experience Builder', { testIsolation: false }, () => {
// New values were typed into the prop form inputs, now enter the iframe
// and confirm the component reflects these new values.
cy.get(
'iframe[data-xb-preview="lg"][data-test-xb-content-initialized="true"]',
).should('exist');
cy.testInIframe('[data-xb-type="experience_builder:my-hero"]', (heroes) => {
const hero = heroes[0];
Object.entries(heroSelectors).forEach(([prop, selector]) => {
......
<?php
declare(strict_types=1);
use Drupal\Component\Serialization\Json;
use Drupal\image\Plugin\Field\FieldType\ImageItem;
use Drupal\node\Entity\Node;
use Drupal\Tests\BrowserTestBase;
class PropSourceEndpointTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'experience_builder',
'sdc_test_all_props',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected $profile = 'standard';
public function test(): void {
$page = $this->getSession()->getPage();
$field_definitions = \Drupal::service('entity_field.manager')->getFieldDefinitions('node', 'article');
$image_field_sample_value = ImageItem::generateSampleValue($field_definitions['field_hero']);
$node = Node::create([
'type' => 'article',
'title' => 'XB Needs This For The Time Being',
'field_hero' => $image_field_sample_value,
]);
$node->save();
$this->drupalLogin($this->rootUser);
$this->drupalGet('xb-components');
$data = Json::decode($page->getText());
$data = array_intersect_key(
$data,
[
'experience_builder:image' => TRUE,
'experience_builder:my-hero' => TRUE,
'sdc_test_all_props:all-props' => TRUE,
],
);
$this->assertCount(3, $data);
$expected = $this->getExpected();
foreach ($data as $sdc => $prop_sources) {
$this->assertSame($expected[$sdc]['dynamic_prop_source_candidates'], $data[$sdc]['dynamic_prop_source_candidates']);
}
}
public function getExpected(): array {
return [
'experience_builder:image' => [
'id' => 'experience_builder:image',
'dynamic_prop_source_candidates' => [
'image' => [
'This Article\'s Hero' => 'ℹ︎␜entity:node:article␝field_hero␞␟{src↝entity␜␜entity:file␝uri␞␟value,alt↠alt,width↠width,height↠height}',
],
],
],
'experience_builder:my-hero' => [
'id' => 'experience_builder:my-hero',
'dynamic_prop_source_candidates' => [
'heading' => [
'This Article\'s Title' => 'ℹ︎␜entity:node:article␝title␞␟value',
],
'subheading' => [
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟title',
"This Article's Image" => 'ℹ︎␜entity:node:article␝field_image␞␟title',
"This Article's Tags" => 'ℹ︎␜entity:node:article␝field_tags␞␟entity␜␜entity:taxonomy_term␝revision_log_message␞␟value',
"This Article's Revision log message" => 'ℹ︎␜entity:node:article␝revision_log␞␟value',
"This Article's Title" => 'ℹ︎␜entity:node:article␝title␞␟value',
],
'cta1' => [
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟title',
"This Article's Image" => 'ℹ︎␜entity:node:article␝field_image␞␟title',
"This Article's Tags" => 'ℹ︎␜entity:node:article␝field_tags␞␟entity␜␜entity:taxonomy_term␝revision_log_message␞␟value',
"This Article's Revision log message" => 'ℹ︎␜entity:node:article␝revision_log␞␟value',
"This Article's Title" => 'ℹ︎␜entity:node:article␝title␞␟value',
],
'cta1href' => [
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟entity␜␜entity:file␝uri␞␟value',
],
'cta2' => [
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟title',
"This Article's Image" => 'ℹ︎␜entity:node:article␝field_image␞␟title',
"This Article's Tags" => 'ℹ︎␜entity:node:article␝field_tags␞␟entity␜␜entity:taxonomy_term␝revision_log_message␞␟value',
"This Article's Revision log message" => 'ℹ︎␜entity:node:article␝revision_log␞␟value',
"This Article's Title" => 'ℹ︎␜entity:node:article␝title␞␟value',
],
],
],
'sdc_test_all_props:all-props' => [
'id' => 'sdc_test_all_props:all-props',
'dynamic_prop_source_candidates' => [
'test_string' => [
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟title',
"This Article's Image" => 'ℹ︎␜entity:node:article␝field_image␞␟title',
"This Article's Tags" => 'ℹ︎␜entity:node:article␝field_tags␞␟entity␜␜entity:taxonomy_term␝revision_log_message␞␟value',
"This Article's Revision log message" => 'ℹ︎␜entity:node:article␝revision_log␞␟value',
"This Article's Title" => 'ℹ︎␜entity:node:article␝title␞␟value',
],
'test_REQUIRED_string' => [
"This Article's Title" => 'ℹ︎␜entity:node:article␝title␞␟value',
],
'test_string_enum' => [],
'test_string_format_date_time' => [],
'test_string_format_date' => [],
'test_string_format_email' => [
"This Article's Revision user" => 'ℹ︎␜entity:node:article␝revision_uid␞␟entity␜␜entity:user␝mail␞␟value',
"This Article's Authored by" => 'ℹ︎␜entity:node:article␝uid␞␟entity␜␜entity:user␝mail␞␟value',
],
'test_string_format_idn_email' => [
"This Article's Revision user" => 'ℹ︎␜entity:node:article␝revision_uid␞␟entity␜␜entity:user␝mail␞␟value',
"This Article's Authored by" => 'ℹ︎␜entity:node:article␝uid␞␟entity␜␜entity:user␝mail␞␟value',
],
'test_string_format_uri' => [
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟entity␜␜entity:file␝uri␞␟value',
"This Article's Image" => 'ℹ︎␜entity:node:article␝field_image␞␟entity␜␜entity:file␝uri␞␟value',
],
'test_string_format_iri' => [
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟entity␜␜entity:file␝uri␞␟value',
"This Article's Image" => 'ℹ︎␜entity:node:article␝field_image␞␟entity␜␜entity:file␝uri␞␟value',
],
'test_integer' => [
"This Article's Changed" => 'ℹ︎␜entity:node:article␝changed␞␟value',
"This Article's Comments" => 'ℹ︎␜entity:node:article␝comment␞␟status',
"This Article's Authored on" => 'ℹ︎␜entity:node:article␝created␞␟value',
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟width',
"This Article's Image" => 'ℹ︎␜entity:node:article␝field_image␞␟width',
"This Article's Tags" => 'ℹ︎␜entity:node:article␝field_tags␞␟target_id',
"This Article's ID" => 'ℹ︎␜entity:node:article␝nid␞␟value',
"This Article's URL alias" => 'ℹ︎␜entity:node:article␝path␞␟pid',
"This Article's Revision create time" => 'ℹ︎␜entity:node:article␝revision_timestamp␞␟value',
"This Article's Revision user" => 'ℹ︎␜entity:node:article␝revision_uid␞␟target_id',
"This Article's Authored by" => 'ℹ︎␜entity:node:article␝uid␞␟target_id',
"This Article's Revision ID" => 'ℹ︎␜entity:node:article␝vid␞␟value',
],
'test_integer_range_minimum' => [],
'test_integer_range_minimum_maximum_timestamps' => [
"This Article's Revision user" => 'ℹ︎␜entity:node:article␝revision_uid␞␟entity␜␜entity:user␝login␞␟value',
"This Article's Authored by" => 'ℹ︎␜entity:node:article␝uid␞␟entity␜␜entity:user␝login␞␟value',
],
'test_object_drupal_image' => [
"This Article's Hero" => 'ℹ︎␜entity:node:article␝field_hero␞␟{src↝entity␜␜entity:file␝uri␞␟value,alt↠alt,width↠width,height↠height}',
],
],
],
];
}
}
......@@ -83,8 +83,8 @@ const Viewport: React.FC<ViewportProps> = (props) => {
if (ev.clone.dataset.isNew === 'true' && ev.clone.dataset.xbUuid) {
// @todo ideally we would use the markup of the component here instead of a loading <p>
if (components) {
const newNode = Object.values(components).find(
if (componentsRef.current) {
const newNode = Object.values(componentsRef.current).find(
(c) => c.id === ev.clone.dataset.xbUuid,
);
if (newNode) {
......@@ -105,7 +105,7 @@ const Viewport: React.FC<ViewportProps> = (props) => {
}
}
},
[dispatch, layout, components],
[dispatch, layout],
);
const handleDragAdd = useCallback(
......@@ -249,7 +249,6 @@ const Viewport: React.FC<ViewportProps> = (props) => {
handleDragStart,
layout,
model,
components,
]);
return (
......
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